From 0d415700582efb10953f25d1f077cb04730b5efa Mon Sep 17 00:00:00 2001 From: Zhe Zhang Date: Tue, 11 Nov 2025 11:57:11 -0800 Subject: [PATCH 01/12] Attempt to support running a template yaml file from CLI --- src/badger/__main__.py | 35 ++-- src/badger/actions/run.py | 167 ++++++++++++++++++ src/badger/db.py | 1 + src/badger/gui/acr/__init__.py | 12 +- src/badger/gui/acr/pages/home_page.py | 36 +++- src/badger/gui/acr/windows/main_window.py | 10 +- src/badger/gui/default/__init__.py | 12 +- src/badger/gui/default/pages/home_page.py | 51 +++++- src/badger/gui/default/windows/main_window.py | 10 +- src/badger/utils.py | 59 +++++++ 10 files changed, 362 insertions(+), 31 deletions(-) diff --git a/src/badger/__main__.py b/src/badger/__main__.py index a174c5e3..d509e11d 100644 --- a/src/badger/__main__.py +++ b/src/badger/__main__.py @@ -11,7 +11,7 @@ from badger.actions.install import plugin_install # noqa: E402 from badger.actions.uninstall import plugin_remove # noqa: E402 from badger.actions.intf import show_intf # noqa: E402 -from badger.actions.run import run_routine # noqa: E402 +from badger.actions.run import run_routine, run_routine_cli # noqa: E402 from badger.actions.config import config_settings # noqa: E402 @@ -97,35 +97,28 @@ def main(): parser_remove.set_defaults(func=plugin_remove) # Parser for the 'run' command - parser_run = subparsers.add_parser("run", help="run routines") - parser_run.add_argument("-a", "--generator", required=True, help="generator to use") - parser_run.add_argument( - "-ap", "--generator_params", help="parameters for the generator" - ) - parser_run.add_argument("-e", "--env", required=True, help="environment to use") - parser_run.add_argument( - "-ep", "--env_params", help="parameters for the environment" + parser_run = subparsers.add_parser( + "run", help="Run optimization from template (YAML file or string)" ) parser_run.add_argument( - "-c", "--config", required=True, help="config for the routine" + "template", help="YAML template (string or file path)" ) parser_run.add_argument( - "-s", "--save", nargs="?", const="", help="the routine name to be saved" + "--gui", + action="store_true", + help="Launch GUI mode (default)", ) parser_run.add_argument( - "-y", "--yes", action="store_true", help="run the routine without confirmation" + "--headless", + action="store_true", + help="Run in headless mode without GUI", ) parser_run.add_argument( - "-v", - "--verbose", - type=int, - choices=[0, 1, 2], - default=2, - const=2, - nargs="?", - help="verbose level of optimization progress", + "--auto-run", + action="store_true", + help="Auto-start optimization without confirmation", ) - parser_run.set_defaults(func=run_routine) + parser_run.set_defaults(func=run_routine_cli) # Parser for the 'config' command parser_config = subparsers.add_parser("config", help="Badger configurations") diff --git a/src/badger/actions/run.py b/src/badger/actions/run.py index c80bdc61..b709fa1d 100644 --- a/src/badger/actions/run.py +++ b/src/badger/actions/run.py @@ -165,3 +165,170 @@ def run_routine(args): # } # run_n_archive(routine, args.yes, args.save, args.verbose) + + +def run_routine_gui(routine, auto_run=False): + """ + Launch ACR GUI with pre-loaded routine. + + Args: + routine: Routine object to load + auto_run: If True, automatically start optimization after loading + """ + from badger.gui.acr import launch_gui + launch_gui(routine=routine, auto_run=auto_run) + + +def run_routine_headless(routine, auto_run=False, verbose=2): + """ + Run routine in headless mode using subprocess. + + Args: + routine: Routine object to run + auto_run: If True, skip confirmation prompt + verbose: Verbosity level (0, 1, or 2) + """ + from multiprocessing import Process, Queue, Event + from badger.core_subprocess import run_routine_subprocess + from badger.archive import archive_run + import tempfile + from badger.utils import get_yaml_string + + # Display routine summary + print(f"\n{'='*60}") + print(f"Routine: {routine.name}") + print(f"Environment: {routine.environment.name}") + print(f"Generator: {routine.generator.name}") + print(f"Variables: {list(routine.vocs.variables.keys())}") + print(f"Objectives: {list(routine.vocs.objectives.keys())}") + if routine.vocs.constraints: + print(f"Constraints: {list(routine.vocs.constraints.keys())}") + print(f"{'='*60}\n") + + # Ask for confirmation if not auto_run + if not auto_run: + try: + response = input("Start optimization? [y/N]: ") + if response.lower() != 'y': + print("Cancelled.") + return + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + return + + # Set up subprocess communication + data_queue = Queue() + stop_event = Event() + pause_event = Event() + pause_event.set() # Start unpaused + + # Save routine temporarily to file + routine_filename = tempfile.mktemp(suffix='.yaml') + with open(routine_filename, 'w') as f: + # Convert routine to YAML + routine_dict = routine.model_dump(mode='json') + f.write(get_yaml_string(routine_dict)) + + # Start subprocess + start_time = time.time() + process = Process( + target=run_routine_subprocess, + args=( + data_queue, + stop_event, + pause_event, + routine_filename, + True, # archive + None, # termination_condition (could add from args) + start_time, + False, # testing + ) + ) + process.start() + + # Monitor progress + print("Optimization started. Press Ctrl+C to stop.\n") + iteration = 0 + last_data = None + + try: + while process.is_alive(): + time.sleep(0.1) + + # Check for data from subprocess + while not data_queue.empty(): + data_dict = data_queue.get() + + if 'error' in data_dict: + print(f"\n❌ Error: {data_dict['error']}") + break + + if 'data' in data_dict: + # Display progress + df = data_dict['data'] + if last_data is None or len(df) > len(last_data): + iteration = len(df) + last_row = df.iloc[-1] + + print(f"Iteration {iteration}:") + for var_name in routine.vocs.variables: + if var_name in last_row: + print(f" {var_name}: {last_row[var_name]:.4f}") + for obj_name in routine.vocs.objectives: + if obj_name in last_row: + print(f" {obj_name}: {last_row[obj_name]:.4f}") + print() + + last_data = df + + except KeyboardInterrupt: + print("\n\nStopping optimization...") + stop_event.set() + + # Wait for completion + process.join(timeout=5) + if process.is_alive(): + process.terminate() + process.join() + + # Final status + elapsed = time.time() - start_time + print(f"\n{'='*60}") + print(f"Optimization completed in {elapsed:.2f}s") + print(f"Total iterations: {iteration}") + print(f"{'='*60}\n") + + # Clean up + if os.path.exists(routine_filename): + os.remove(routine_filename) + + +def run_routine_cli(args): + """ + Main CLI handler for running routines from templates. + + Args: + args: Parsed command-line arguments + """ + try: + # Load template using smart detection + from badger.utils import load_template_smart + config = load_template_smart(args.template) + + # Create routine from template + routine = Routine(**config) + + # Determine mode (default to GUI if neither specified) + if args.headless: + # Headless subprocess mode + run_routine_headless(routine, auto_run=args.auto_run) + else: + # GUI mode (default) + run_routine_gui(routine, auto_run=args.auto_run) + + except Exception as e: + logger.error(f"Error running routine: {e}") + print(f"Error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/src/badger/db.py b/src/badger/db.py index 32ad4b2f..f433125d 100644 --- a/src/badger/db.py +++ b/src/badger/db.py @@ -20,6 +20,7 @@ try: BADGER_DB_ROOT = config_singleton.read_value("BADGER_DB_ROOT") except KeyError: + BADGER_DB_ROOT = None # FIX: Define variable even when not configured flag_use_db = False if flag_use_db: diff --git a/src/badger/gui/acr/__init__.py b/src/badger/gui/acr/__init__.py index 871dc540..5f2d5258 100644 --- a/src/badger/gui/acr/__init__.py +++ b/src/badger/gui/acr/__init__.py @@ -78,7 +78,15 @@ def error_handler( raise BadgerError(error_title, error_msg) -def launch_gui(config_path=None): +def launch_gui(config_path=None, routine=None, auto_run=False): + """ + Launch the Badger ACR GUI. + + Args: + config_path: Optional path to configuration file + routine: Optional Routine object to pre-load in GUI + auto_run: If True and routine is provided, automatically start optimization + """ sys.excepthook = error_handler app = QApplication(sys.argv) @@ -109,7 +117,7 @@ def launch_gui(config_path=None): app.setStyleSheet("") # Show the main window - window = BadgerMainWindow() + window = BadgerMainWindow(routine=routine, auto_run=auto_run) # Enable Ctrl + C quit signal.signal(signal.SIGINT, on_exit) diff --git a/src/badger/gui/acr/pages/home_page.py b/src/badger/gui/acr/pages/home_page.py index 81fbe4a3..98c6929d 100644 --- a/src/badger/gui/acr/pages/home_page.py +++ b/src/badger/gui/acr/pages/home_page.py @@ -63,7 +63,7 @@ class BadgerHomePage(QWidget): sig_routine_activated = pyqtSignal(bool) sig_routine_invalid = pyqtSignal() - def __init__(self, process_manager=None): + def __init__(self, process_manager=None, routine=None, auto_run=False): super().__init__() self.mode = "regular" # home page mode @@ -77,6 +77,11 @@ def __init__(self, process_manager=None): self.load_all_runs() self.init_home_page() + # Auto-load routine from CLI if provided + if routine is not None: + from PyQt5.QtCore import QTimer + QTimer.singleShot(100, lambda: self.load_routine_from_cli(routine, auto_run)) + def init_ui(self): self.config_singleton = init_settings() icon_ref = resources.files(__package__) / "../images/add.png" @@ -430,3 +435,32 @@ def uncover_page(self): self.overlay.hide() except AttributeError: # in test mode pass + + def load_routine_from_cli(self, routine, auto_run): + """ + Load routine from CLI and optionally auto-start optimization. + + This method is called when a routine is provided via CLI. + It loads the routine into the editor and optionally triggers a run. + + Args: + routine: Routine object to load + auto_run: If True, automatically start optimization + """ + # Set the routine in the editor (existing method) + self.routine_editor.set_routine(routine, silent=True) + + # Update current routine + self.current_routine = routine + + # Initialize plots and monitor (reuse existing logic) + self.run_monitor.init_plots(routine) + + # Update data table if routine has data + if routine.data is not None and len(routine.data) > 0: + update_table(self.run_table, routine.sorted_data, routine.vocs) + + # If auto-run requested, start optimization after short delay + if auto_run: + from PyQt5.QtCore import QTimer + QTimer.singleShot(500, self.start_run) diff --git a/src/badger/gui/acr/windows/main_window.py b/src/badger/gui/acr/windows/main_window.py index fc21cd2b..34ed19fe 100644 --- a/src/badger/gui/acr/windows/main_window.py +++ b/src/badger/gui/acr/windows/main_window.py @@ -11,12 +11,14 @@ class BadgerMainWindow(QMainWindow): - def __init__(self) -> None: + def __init__(self, routine=None, auto_run=False) -> None: super().__init__() self.thread_list = [] self.process_manager = ProcessManager() self.process_manager.processQueueUpdated.connect(self.addSubprocess) self.addSubprocess() + self.routine = routine + self.auto_run = auto_run self.init_ui() self.config_logic() @@ -75,7 +77,11 @@ def init_ui(self) -> None: # edit_menu.addAction('New') # Add pages - self.home_page = BadgerHomePage(self.process_manager) + self.home_page = BadgerHomePage( + self.process_manager, + routine=self.routine, + auto_run=self.auto_run + ) self.stacks = stacks = QStackedWidget() stacks.addWidget(self.home_page) diff --git a/src/badger/gui/default/__init__.py b/src/badger/gui/default/__init__.py index 04332547..02c14007 100644 --- a/src/badger/gui/default/__init__.py +++ b/src/badger/gui/default/__init__.py @@ -78,7 +78,15 @@ def error_handler( raise BadgerError(error_title, error_msg) -def launch_gui(config_path=None): +def launch_gui(config_path=None, routine=None, auto_run=False): + """ + Launch the Badger GUI. + + Args: + config_path: Optional path to configuration file + routine: Optional Routine object to pre-load in GUI + auto_run: If True and routine is provided, automatically start optimization + """ sys.excepthook = error_handler app = QApplication(sys.argv) @@ -109,7 +117,7 @@ def launch_gui(config_path=None): app.setStyleSheet("") # Show the main window - window = BadgerMainWindow() + window = BadgerMainWindow(routine=routine, auto_run=auto_run) # Enable Ctrl + C quit signal.signal(signal.SIGINT, on_exit) diff --git a/src/badger/gui/default/pages/home_page.py b/src/badger/gui/default/pages/home_page.py index dc18f93a..3055b7ce 100644 --- a/src/badger/gui/default/pages/home_page.py +++ b/src/badger/gui/default/pages/home_page.py @@ -73,7 +73,7 @@ class BadgerHomePage(QWidget): sig_routine_activated = pyqtSignal(bool) - def __init__(self, process_manager=None): + def __init__(self, process_manager=None, routine=None, auto_run=False): super().__init__() self.mode = "regular" # home page mode @@ -87,6 +87,11 @@ def __init__(self, process_manager=None): self.load_all_runs() + # Auto-load routine from CLI if provided + if routine is not None: + from PyQt5.QtCore import QTimer + QTimer.singleShot(100, lambda: self.load_routine_from_cli(routine, auto_run)) + def init_ui(self): self.config_singleton = init_settings() icon_ref = resources.files(__package__) / "../images/add.png" @@ -688,3 +693,47 @@ def uncover_page(self): self.overlay.hide() except AttributeError: # in test mode pass + + def load_routine_from_cli(self, routine, auto_run): + """ + Load routine from CLI and optionally start optimization. + + This method is called when a routine is provided via CLI. + It loads the routine into the editor and optionally triggers a run. + + Args: + routine: Routine object to load + auto_run: If True, automatically start optimization + """ + # Switch to routine editor tab + self.tabs.setCurrentIndex(1) + + # Load routine into editor using existing method + self.routine_page.refresh_ui(routine) + + # If auto_run is requested, trigger run after a short delay + if auto_run: + from PyQt5.QtCore import QTimer + QTimer.singleShot(500, self.trigger_run_from_cli) + + def trigger_run_from_cli(self): + """ + Trigger optimization run from CLI (auto-run mode). + + This simulates clicking the "Run" button in the GUI. + """ + # Get the routine from the editor + routine = self.routine_page.routine + + if routine is None: + print("Error: No routine loaded in editor") + return + + # Switch to run monitor tab + self.tabs.setCurrentIndex(2) + + # Set up run monitor with routine + self.run_monitor.set_routine(routine) + + # Start the run (uses existing start() method) + self.run_monitor.start() diff --git a/src/badger/gui/default/windows/main_window.py b/src/badger/gui/default/windows/main_window.py index 8e3a261f..a709315a 100644 --- a/src/badger/gui/default/windows/main_window.py +++ b/src/badger/gui/default/windows/main_window.py @@ -11,12 +11,14 @@ class BadgerMainWindow(QMainWindow): - def __init__(self) -> None: + def __init__(self, routine=None, auto_run=False) -> None: super().__init__() self.thread_list = [] self.process_manager = ProcessManager() self.process_manager.processQueueUpdated.connect(self.addSubprocess) self.addSubprocess() + self.routine = routine + self.auto_run = auto_run self.init_ui() self.config_logic() @@ -75,7 +77,11 @@ def init_ui(self) -> None: # edit_menu.addAction('New') # Add pages - self.home_page = BadgerHomePage(self.process_manager) + self.home_page = BadgerHomePage( + self.process_manager, + routine=self.routine, + auto_run=self.auto_run + ) self.stacks = stacks = QStackedWidget() stacks.addWidget(self.home_page) diff --git a/src/badger/utils.py b/src/badger/utils.py index fd85cec6..6d05b223 100644 --- a/src/badger/utils.py +++ b/src/badger/utils.py @@ -81,6 +81,65 @@ def load_config(fname): return configs +def load_template_smart(template_input): + """ + Load routine template from YAML string or file path. + + Args: + template_input: Either a YAML string or a file path + + Returns: + dict: Parsed template configuration + + Raises: + BadgerLoadConfigError: If template cannot be loaded or parsed + """ + if template_input is None: + raise BadgerLoadConfigError("Template input cannot be None") + + # Try parsing as YAML string first + if not os.path.exists(template_input): + try: + config = yaml.safe_load(template_input) + # If result is a dict, it's valid template YAML + if isinstance(config, dict): + return config + # If result is a string, it wasn't actually YAML, likely a file path + elif isinstance(config, str): + raise BadgerLoadConfigError( + f"Template file not found: {template_input}" + ) + # Other types (None, list, etc.) are invalid + else: + raise BadgerLoadConfigError( + f"Invalid template: expected dict, got {type(config).__name__}" + ) + except yaml.YAMLError as e: + raise BadgerLoadConfigError( + f"Invalid YAML string: {str(e)}" + ) + + # Load from file path + try: + with open(template_input, 'r') as f: + config = yaml.safe_load(f) + + if not isinstance(config, dict): + raise BadgerLoadConfigError( + f"Invalid template file: expected dict, got {type(config).__name__}" + ) + + return config + except yaml.YAMLError as e: + raise BadgerLoadConfigError( + f"Error parsing template file {template_input}: {str(e)}" + ) + except IOError as e: + raise BadgerLoadConfigError( + f"Error reading template file {template_input}: {str(e)}" + ) + + def merge_params(default_params, params): merged_params = None From 47e9c185ac8080812f10b2412bd8e9f6cf69a91d Mon Sep 17 00:00:00 2001 From: Zhe Zhang Date: Tue, 11 Nov 2025 12:10:11 -0800 Subject: [PATCH 02/12] Fill in the initial data table when load template from CLI --- src/badger/gui/acr/pages/home_page.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/badger/gui/acr/pages/home_page.py b/src/badger/gui/acr/pages/home_page.py index 98c6929d..d409c4e4 100644 --- a/src/badger/gui/acr/pages/home_page.py +++ b/src/badger/gui/acr/pages/home_page.py @@ -450,6 +450,12 @@ def load_routine_from_cli(self, routine, auto_run): # Set the routine in the editor (existing method) self.routine_editor.set_routine(routine, silent=True) + # Populate initial points table based on actions (like "Load Template" does) + # This ensures actions like "add_curr" and "add_rand" are executed + if hasattr(self.routine_editor, 'init_table_actions') and self.routine_editor.init_table_actions: + self.routine_editor.clear_init_table(reset_actions=False) + self.routine_editor.update_init_table(force=True) + # Update current routine self.current_routine = routine From 1c3cd8c87becb846bb0f1b43e159e038458b6cd8 Mon Sep 17 00:00:00 2001 From: Zhe Zhang Date: Tue, 11 Nov 2025 13:26:35 -0800 Subject: [PATCH 03/12] Make headless CLI run work But not too stable for now, seems to have a race condition or timing issue, need to look at it at some point in the future. --- src/badger/actions/run.py | 107 +++++++++++++++++++++++----------- src/badger/core_subprocess.py | 2 +- 2 files changed, 74 insertions(+), 35 deletions(-) diff --git a/src/badger/actions/run.py b/src/badger/actions/run.py index b709fa1d..aff026be 100644 --- a/src/badger/actions/run.py +++ b/src/badger/actions/run.py @@ -188,11 +188,10 @@ def run_routine_headless(routine, auto_run=False, verbose=2): auto_run: If True, skip confirmation prompt verbose: Verbosity level (0, 1, or 2) """ - from multiprocessing import Process, Queue, Event + from multiprocessing import Process, Queue, Event, Pipe from badger.core_subprocess import run_routine_subprocess - from badger.archive import archive_run - import tempfile - from badger.utils import get_yaml_string + from badger.archive import save_tmp_run + from badger.settings import init_settings # Display routine summary print(f"\n{'='*60}") @@ -216,36 +215,74 @@ def run_routine_headless(routine, auto_run=False, verbose=2): print("\nCancelled.") return - # Set up subprocess communication + # Set up subprocess communication (matching GUI architecture) + # CRITICAL: Must create these BEFORE starting subprocess with 'spawn' data_queue = Queue() + evaluate_queue = Pipe() stop_event = Event() pause_event = Event() - pause_event.set() # Start unpaused - - # Save routine temporarily to file - routine_filename = tempfile.mktemp(suffix='.yaml') - with open(routine_filename, 'w') as f: - # Convert routine to YAML - routine_dict = routine.model_dump(mode='json') - f.write(get_yaml_string(routine_dict)) + wait_event = Event() + config_path = init_settings()._instance.config_path - # Start subprocess - start_time = time.time() + # Start subprocess FIRST (matching GUI pattern) process = Process( target=run_routine_subprocess, args=( data_queue, + evaluate_queue, stop_event, pause_event, - routine_filename, - True, # archive - None, # termination_condition (could add from args) - start_time, - False, # testing + wait_event, + config_path, ) ) process.start() + # Give subprocess time to start and reach wait_event.wait() + # With 'spawn' on macOS, starting Python interpreter takes time + time.sleep(3) + + # NOW calculate initial points and prepare data + from badger.routine import calculate_initial_points + import pandas as pd + + if routine.initial_points is None or len(routine.initial_points) == 0: + init_points = calculate_initial_points( + routine.initial_point_actions, + routine.vocs, + routine.environment, + ) + try: + init_points = pd.DataFrame(init_points) + except (IndexError, ValueError): + init_points = pd.DataFrame(init_points, index=[0]) + routine.initial_points = init_points + + # Record start time and save routine + start_time = time.time() + routine_filename = save_tmp_run(routine) + + # Prepare arguments to send to subprocess + arg_dict = { + "routine_id": routine.id if hasattr(routine, 'id') else None, + "routine_filename": routine_filename, + "routine_name": routine.name, + "variable_ranges": routine.vocs.variables, + "initial_points": routine.initial_points, + "evaluate": True, + "archive": True, + "termination_condition": None, + "start_time": start_time, + "testing": False, + } + + # NOW put data in queue (subprocess is already running and waiting) + data_queue.put(arg_dict) + + # Signal subprocess to begin execution + pause_event.set() # Start unpaused + wait_event.set() # Signal subprocess to begin + # Monitor progress print("Optimization started. Press Ctrl+C to stop.\n") iteration = 0 @@ -255,17 +292,13 @@ def run_routine_headless(routine, auto_run=False, verbose=2): while process.is_alive(): time.sleep(0.1) - # Check for data from subprocess - while not data_queue.empty(): - data_dict = data_queue.get() + # Check for data from subprocess via evaluate_queue (Pipe) + # This is how the GUI does it - evaluate_queue sends (data, generator) tuples + if evaluate_queue[1].poll(): + while evaluate_queue[1].poll(): + results = evaluate_queue[1].recv() + df = results[0] # First element is the data DataFrame - if 'error' in data_dict: - print(f"\n❌ Error: {data_dict['error']}") - break - - if 'data' in data_dict: - # Display progress - df = data_dict['data'] if last_data is None or len(df) > len(last_data): iteration = len(df) last_row = df.iloc[-1] @@ -281,6 +314,16 @@ def run_routine_headless(routine, auto_run=False, verbose=2): last_data = df + # Check for errors in data_queue + if not data_queue.empty(): + try: + error_title, error_traceback = data_queue.get() + print(f"\n❌ Error: {error_title}") + print(error_traceback) + break + except ValueError: + pass + except KeyboardInterrupt: print("\n\nStopping optimization...") stop_event.set() @@ -298,10 +341,6 @@ def run_routine_headless(routine, auto_run=False, verbose=2): print(f"Total iterations: {iteration}") print(f"{'='*60}\n") - # Clean up - if os.path.exists(routine_filename): - os.remove(routine_filename) - def run_routine_cli(args): """ diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index 44d30032..67d8bb23 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -93,7 +93,7 @@ def run_routine_subprocess( wait_event.wait() try: - args = queue.get(timeout=1) + args = queue.get(timeout=5) except Exception as e: print(f"Error in subprocess: {type(e).__name__}, {str(e)}") From 7405ca04633defb703e662f968a10af12528ffbf Mon Sep 17 00:00:00 2001 From: Zhe Zhang Date: Tue, 11 Nov 2025 20:40:31 -0800 Subject: [PATCH 04/12] Support pause/resume in CLI run --- src/badger/actions/run.py | 106 +++++++++++++++++++++------------- src/badger/core_subprocess.py | 9 +++ 2 files changed, 74 insertions(+), 41 deletions(-) diff --git a/src/badger/actions/run.py b/src/badger/actions/run.py index aff026be..ffba5091 100644 --- a/src/badger/actions/run.py +++ b/src/badger/actions/run.py @@ -283,50 +283,74 @@ def run_routine_headless(routine, auto_run=False, verbose=2): pause_event.set() # Start unpaused wait_event.set() # Signal subprocess to begin - # Monitor progress - print("Optimization started. Press Ctrl+C to stop.\n") + # Monitor progress with pause/resume support using signal handler (like old run_n_archive) + print("Optimization started. Press Ctrl+C to pause.\n") iteration = 0 last_data = None - try: - while process.is_alive(): - time.sleep(0.1) - - # Check for data from subprocess via evaluate_queue (Pipe) - # This is how the GUI does it - evaluate_queue sends (data, generator) tuples - if evaluate_queue[1].poll(): - while evaluate_queue[1].poll(): - results = evaluate_queue[1].recv() - df = results[0] # First element is the data DataFrame - - if last_data is None or len(df) > len(last_data): - iteration = len(df) - last_row = df.iloc[-1] - - print(f"Iteration {iteration}:") - for var_name in routine.vocs.variables: - if var_name in last_row: - print(f" {var_name}: {last_row[var_name]:.4f}") - for obj_name in routine.vocs.objectives: - if obj_name in last_row: - print(f" {obj_name}: {last_row[obj_name]:.4f}") - print() - - last_data = df - - # Check for errors in data_queue - if not data_queue.empty(): - try: - error_title, error_traceback = data_queue.get() - print(f"\n❌ Error: {error_title}") - print(error_traceback) - break - except ValueError: - pass - - except KeyboardInterrupt: - print("\n\nStopping optimization...") - stop_event.set() + # Storage for signal handler state + storage = {"paused": False, "should_exit": False} + + def sigint_handler(*args): + """Signal handler for Ctrl+C - sets pause flag or exit flag""" + if storage["paused"]: + # Second Ctrl+C while paused - request exit + print("") # new line + storage["should_exit"] = True + else: + # First Ctrl+C - request pause + storage["paused"] = True + + # Install signal handler + signal.signal(signal.SIGINT, sigint_handler) + + # Main monitoring loop - check pause flag instead of using try-except + while process.is_alive() and not storage["should_exit"]: + time.sleep(0.1) + + # Check if paused - handle pause prompt + if storage["paused"]: + pause_event.clear() # Pause subprocess + print("") # new line + + res = input("Optimization paused. Press Enter to resume or Ctrl+C to terminate: ") + while res != "": + # Invalid input, ask again + sys.stdout.write("\033[F") # Move cursor up to erase line + res = input("Invalid choice. Press Enter to resume or Ctrl+C to terminate: ") + + # Check if exit was requested during pause + if storage["should_exit"]: + print("\nStopping optimization...") + stop_event.set() + break + + # Resume + print("Resuming optimization...\n") + storage["paused"] = False + pause_event.set() # Resume subprocess + + # Check for data from subprocess via evaluate_queue (Pipe) + if evaluate_queue[1].poll(): + while evaluate_queue[1].poll(): + results = evaluate_queue[1].recv() + df = results[0] # First element is the data DataFrame + if len(df) > iteration: + iteration = len(df) + last_data = df + + # Check for errors in data_queue + if not data_queue.empty(): + try: + error_title, error_traceback = data_queue.get() + print(f"\n❌ Error: {error_title}") + print(error_traceback) + break + except ValueError: + pass + + # Restore default signal handler + signal.signal(signal.SIGINT, signal.SIG_DFL) # Wait for completion process.join(timeout=5) diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index 67d8bb23..449dbe94 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -1,5 +1,6 @@ from copy import deepcopy import logging +import signal import time import traceback from pandas import DataFrame @@ -90,6 +91,10 @@ def run_routine_subprocess( # Now load the archive would use the correct config from badger.archive import load_run, archive_run + # Ignore SIGINT (Ctrl+C) in subprocess - only parent should handle it + # Subprocess is controlled via multiprocessing Events (pause_event, stop_event) + signal.signal(signal.SIGINT, signal.SIG_IGN) + wait_event.wait() try: @@ -231,6 +236,10 @@ def run_routine_subprocess( except BadgerRunTerminated: opt_logger.update(Events.OPTIMIZATION_END, solution_meta) evaluate_queue[0].close() + except KeyboardInterrupt: + # Clean exit on user interrupt - don't print traceback + opt_logger.update(Events.OPTIMIZATION_END, solution_meta) + evaluate_queue[0].close() except XoptError as e: opt_logger.update(Events.OPTIMIZATION_END, solution_meta) error_title = "BadgerEnvObsError: There was an error getting observables from the environment. See the traceback for more details." From dc4e02b1b0cce4939f8232e4dc1d3afd408c4427 Mon Sep 17 00:00:00 2001 From: Zhe Zhang Date: Tue, 11 Nov 2025 23:56:35 -0800 Subject: [PATCH 05/12] Now the second Ctrl + C can peacefully terminate the run in CLI mode --- src/badger/actions/run.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/badger/actions/run.py b/src/badger/actions/run.py index ffba5091..2999bc39 100644 --- a/src/badger/actions/run.py +++ b/src/badger/actions/run.py @@ -292,11 +292,12 @@ def run_routine_headless(routine, auto_run=False, verbose=2): storage = {"paused": False, "should_exit": False} def sigint_handler(*args): - """Signal handler for Ctrl+C - sets pause flag or exit flag""" + """Signal handler for Ctrl+C - sets pause flag or raises to exit""" if storage["paused"]: - # Second Ctrl+C while paused - request exit + # Second Ctrl+C while paused - raise to interrupt input() and exit print("") # new line storage["should_exit"] = True + raise KeyboardInterrupt # Interrupt the input() call else: # First Ctrl+C - request pause storage["paused"] = True @@ -313,11 +314,15 @@ def sigint_handler(*args): pause_event.clear() # Pause subprocess print("") # new line - res = input("Optimization paused. Press Enter to resume or Ctrl+C to terminate: ") - while res != "": - # Invalid input, ask again - sys.stdout.write("\033[F") # Move cursor up to erase line - res = input("Invalid choice. Press Enter to resume or Ctrl+C to terminate: ") + try: + res = input("Optimization paused. Press Enter to resume or Ctrl+C to terminate: ") + while res != "": + # Invalid input, ask again + sys.stdout.write("\033[F") # Move cursor up to erase line + res = input("Invalid choice. Press Enter to resume or Ctrl+C to terminate: ") + except KeyboardInterrupt: + # Ctrl+C pressed during input - signal handler already set should_exit=True + pass # Check if exit was requested during pause if storage["should_exit"]: From 3116b52e587f434c10cb27f10e6f35699e16ddb4 Mon Sep 17 00:00:00 2001 From: Zhe Zhang Date: Wed, 22 Apr 2026 10:45:27 -0700 Subject: [PATCH 06/12] Fix BadgerListEditor dropping nested subtype info --- src/badger/gui/components/pydantic_editor.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/badger/gui/components/pydantic_editor.py b/src/badger/gui/components/pydantic_editor.py index 044d70b2..7025d11b 100644 --- a/src/badger/gui/components/pydantic_editor.py +++ b/src/badger/gui/components/pydantic_editor.py @@ -194,11 +194,14 @@ def resolve( @classmethod def resolve_qt( cls, - annotation: type[Any] | Union[Any, None] | None, + annotation: "type[Any] | Union[Any, None] | BadgerResolvedType | None", default: float | int | bool | dict[str, Any] | list[Any] | None = None, editor_info: tuple["BadgerPydanticEditor", QTreeWidgetItem] | None = None, ) -> QWidget | None: - resolved_type = BadgerResolvedType.resolve(annotation) + if isinstance(annotation, BadgerResolvedType): + resolved_type = annotation + else: + resolved_type = BadgerResolvedType.resolve(annotation) widget = QLabel() if resolved_type.main is None: @@ -235,7 +238,7 @@ def resolve_qt( if primary_type.main is None or secondary_type.main is None: raise ValueError("Dict subtypes must be basic types") - widget = BadgerListEditor(primary_type.main, secondary_type.main) + widget = BadgerListEditor(primary_type, secondary_type) if default is not None and isinstance(default, dict): for k, v in default.items(): @@ -260,7 +263,7 @@ def resolve_qt( if primary_type.main is None: raise ValueError("List subtype must be a basic type") widget = BadgerListEditor( - primary_type.main, secondary_type.main if secondary_type else None + primary_type, secondary_type if secondary_type else None ) if default is not None and isinstance(default, list): @@ -475,8 +478,8 @@ class BadgerListEditor(QWidget): def __init__( self, - widget_type: type[Any], - widget_type2: type[Any] | None = None, + widget_type: "type[Any] | BadgerResolvedType", + widget_type2: "type[Any] | BadgerResolvedType | None" = None, parent: QWidget | None = None, ): super().__init__(parent) From 4f1e1eed4d64bb9815c1ee2a8e0b2d213ad99c7c Mon Sep 17 00:00:00 2001 From: Zhe Zhang Date: Wed, 22 Apr 2026 10:50:38 -0700 Subject: [PATCH 07/12] Add missing keys to CLI run --- src/badger/actions/run.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/badger/actions/run.py b/src/badger/actions/run.py index 0c50a566..9557f1af 100644 --- a/src/badger/actions/run.py +++ b/src/badger/actions/run.py @@ -274,6 +274,8 @@ def run_routine_headless(routine, auto_run=False, verbose=2): "termination_condition": None, "start_time": start_time, "testing": False, + "run_data": False, + "init_points": True, } # NOW put data in queue (subprocess is already running and waiting) From dcf320de3a356fae10a0a9459466e5512de39ae4 Mon Sep 17 00:00:00 2001 From: Zhe Zhang Date: Wed, 13 May 2026 17:23:14 -0700 Subject: [PATCH 08/12] Add more remote controls to Badger --- src/badger/__main__.py | 12 +++ src/badger/actions/run.py | 14 +++- src/badger/core_subprocess.py | 26 +++++++ src/badger/gui/__init__.py | 10 ++- src/badger/gui/pages/home_page.py | 101 +++++++++++++++++++++++++- src/badger/gui/windows/main_window.py | 6 +- 6 files changed, 161 insertions(+), 8 deletions(-) diff --git a/src/badger/__main__.py b/src/badger/__main__.py index 07fe903b..d96e91ca 100644 --- a/src/badger/__main__.py +++ b/src/badger/__main__.py @@ -122,6 +122,18 @@ def main(): action="store_true", help="Auto-start optimization without confirmation", ) + parser_run.add_argument( + "--watch-routine", + type=str, + default=None, + help=( + "Path to a routine YAML the GUI should watch for changes. " + "When the file is modified (e.g. by an external agent " + "supplying the next routine in a campaign), the GUI stops " + "any active run, reloads the routine, and (if --auto-run " + "was set) restarts. GUI mode only." + ), + ) parser_run.set_defaults(func=run_routine_cli) # Parser for the 'config' command diff --git a/src/badger/actions/run.py b/src/badger/actions/run.py index 9557f1af..306380e5 100644 --- a/src/badger/actions/run.py +++ b/src/badger/actions/run.py @@ -167,16 +167,19 @@ def run_routine(args): # run_n_archive(routine, args.yes, args.save, args.verbose) -def run_routine_gui(routine, auto_run=False): +def run_routine_gui(routine, auto_run=False, watch_routine=None): """ Launch Badger GUI with pre-loaded routine. Args: routine: Routine object to load auto_run: If True, automatically start optimization after loading + watch_routine: Optional path to a YAML file the GUI should watch + for changes; on file modification the GUI stops any active + run, reloads, and re-starts (auto_run=True only). """ from badger.gui import launch_gui - launch_gui(routine=routine, auto_run=auto_run) + launch_gui(routine=routine, auto_run=auto_run, watch_routine=watch_routine) def run_routine_headless(routine, auto_run=False, verbose=2): @@ -394,7 +397,12 @@ def run_routine_cli(args): run_routine_headless(routine, auto_run=args.auto_run) else: # GUI mode (default) - run_routine_gui(routine, auto_run=args.auto_run) + watch_routine = getattr(args, "watch_routine", None) + run_routine_gui( + routine, + auto_run=args.auto_run, + watch_routine=watch_routine, + ) except Exception as e: logger.error(f"Error running routine: {e}") diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index b2d43743..8c19817b 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -1,4 +1,5 @@ from copy import deepcopy +import json import logging import signal import time @@ -22,6 +23,29 @@ logger = logging.getLogger(__name__) +def _emit_live_log(routine: "Routine", is_optimal: bool) -> None: + """ + If BADGER_LIVE_LOG_PATH is set in the environment, append a JSONL + record describing the most-recent evaluation. Used by external + agents (e.g. Otter's auto-tune skill) to monitor a running Badger + optimization without screen-scraping the GUI or tailing the run + archive YAML. + + Side-effect-free when the env var is unset. Never raises. + """ + live_log = os.environ.get("BADGER_LIVE_LOG_PATH") + if not live_log: + return + try: + last = routine.data.iloc[-1].to_dict() + last["iteration"] = len(routine.data) - 1 + last["is_optimal"] = bool(is_optimal) + with open(live_log, "a") as f: + f.write(json.dumps(last, default=str) + "\n") + except Exception as exc: # never let logging crash the optimizer + logger.debug("BADGER_LIVE_LOG_PATH write failed: %s", exc) + + def convert_to_solution(result: DataFrame, routine: Routine): """ This method is passed the latest evaluated solution and converts that to a printable format for the terminal. @@ -226,6 +250,7 @@ def run_routine_subprocess( if evaluate: time.sleep(0.1) # give it some break tp catch up evaluate_queue[0].send((routine.data, routine.generator)) + _emit_live_log(routine, solution[4]) logger.info("Starting optimization loop...") while True: @@ -292,6 +317,7 @@ def run_routine_subprocess( if evaluate: logger.debug("Sending evaluation data to evaluate_queue.") evaluate_queue[0].send((routine.data, generator_copy)) + _emit_live_log(routine, solution[4]) if archive: if not testing: diff --git a/src/badger/gui/__init__.py b/src/badger/gui/__init__.py index 0ff15c4f..95b587a8 100644 --- a/src/badger/gui/__init__.py +++ b/src/badger/gui/__init__.py @@ -81,7 +81,7 @@ def error_handler( raise BadgerError(error_title, error_msg) -def launch_gui(config_path=None, routine=None, auto_run=False): +def launch_gui(config_path=None, routine=None, auto_run=False, watch_routine=None): """ Launch the Badger GUI. @@ -89,6 +89,10 @@ def launch_gui(config_path=None, routine=None, auto_run=False): config_path: Optional path to configuration file routine: Optional Routine object to pre-load in GUI auto_run: If True and routine is provided, automatically start optimization + watch_routine: Optional path to a routine YAML the GUI should + watch; on file modification, the GUI stops any active run + and reloads + (auto-)restarts. Used by external agents + running a multi-stage tuning campaign. """ sys.excepthook = error_handler @@ -120,7 +124,9 @@ def launch_gui(config_path=None, routine=None, auto_run=False): app.setStyleSheet("") # Show the main window - window = BadgerMainWindow(routine=routine, auto_run=auto_run) + window = BadgerMainWindow( + routine=routine, auto_run=auto_run, watch_routine=watch_routine + ) # Enable Ctrl + C quit signal.signal(signal.SIGINT, on_exit) diff --git a/src/badger/gui/pages/home_page.py b/src/badger/gui/pages/home_page.py index afc0a223..33c2038b 100644 --- a/src/badger/gui/pages/home_page.py +++ b/src/badger/gui/pages/home_page.py @@ -73,7 +73,13 @@ class BadgerHomePage(QWidget): sig_routine_activated = pyqtSignal(bool) sig_routine_invalid = pyqtSignal() - def __init__(self, process_manager=None, routine=None, auto_run=False): + def __init__( + self, + process_manager=None, + routine=None, + auto_run=False, + watch_routine=None, + ): logger.info("Initializing BadgerHomePage.") super().__init__() @@ -82,6 +88,10 @@ def __init__(self, process_manager=None, routine=None, auto_run=False): self.process_manager = process_manager self.current_routine = None # current routine self.go_run_failed = False # flag to indicate go_run failed + # Watch-routine state (campaign-mode reload trigger) + self._watch_routine_path = watch_routine + self._watch_auto_run = auto_run + self._watch_fs_watcher = None self.init_ui() self.config_logic() @@ -93,6 +103,95 @@ def __init__(self, process_manager=None, routine=None, auto_run=False): from PyQt5.QtCore import QTimer QTimer.singleShot(100, lambda: self.load_routine_from_cli(routine, auto_run)) + # Install file-system watcher for campaign-mode routine swaps + if self._watch_routine_path: + from PyQt5.QtCore import QTimer + QTimer.singleShot(200, self._install_routine_watcher) + + def _install_routine_watcher(self): + """ + Install a QFileSystemWatcher on `self._watch_routine_path`. When + the file changes (an external agent has written a new routine + YAML in place), stop the currently-running optimization (if any), + reload the routine, and (if --auto-run was set) restart. + """ + from PyQt5.QtCore import QFileSystemWatcher + path = self._watch_routine_path + if not path or not os.path.isfile(path): + logger.warning( + "watch-routine: file does not exist yet (%s); " + "watcher will be (re)installed on first change.", + path, + ) + self._watch_fs_watcher = QFileSystemWatcher([path] if os.path.isfile(path) else [], self) + self._watch_fs_watcher.fileChanged.connect(self._on_watch_routine_changed) + # Also watch the parent dir so file-replacement (atomic mv) is caught + parent = os.path.dirname(path) or "." + if os.path.isdir(parent): + self._watch_fs_watcher.addPath(parent) + self._watch_fs_watcher.directoryChanged.connect( + self._on_watch_dir_changed + ) + logger.info("watch-routine: watching %s", path) + + def _on_watch_dir_changed(self, _changed_dir): + # Re-add the file path in case it was atomically replaced + # (replacement deletes the inode → fileChanged stops firing). + path = self._watch_routine_path + if path and os.path.isfile(path): + if path not in self._watch_fs_watcher.files(): + self._watch_fs_watcher.addPath(path) + # Trigger a reload too — directory changed because the + # file appeared / was replaced. + self._on_watch_routine_changed(path) + + def _on_watch_routine_changed(self, path): + """ + File-watcher callback: stop any running routine, load the new + YAML, and (if auto-run was requested at launch) start it. + Debounced so editors that save in multiple writes don't trigger + a thrash. + """ + from PyQt5.QtCore import QTimer + if not hasattr(self, "_watch_pending"): + self._watch_pending = False + if self._watch_pending: + return + self._watch_pending = True + # 300ms debounce — long enough to absorb an editor's atomic save, + # short enough to feel responsive to a deliberate "swap routine". + QTimer.singleShot(300, lambda: self._do_watch_reload(path)) + + def _do_watch_reload(self, path): + self._watch_pending = False + try: + if not os.path.isfile(path): + logger.warning("watch-routine: %s no longer exists; skip reload", path) + return + + # Stop any active run, gracefully. + try: + if ( + self.run_monitor is not None + and getattr(self.run_monitor, "running", False) + ): + logger.info("watch-routine: stopping current run before reload") + self.run_monitor.sig_stop.emit() + except Exception as exc: + logger.warning("watch-routine: stop emit failed: %s", exc) + + # Load the new routine + from badger.utils import load_template_smart + from badger.routine import Routine + config = load_template_smart(path) + routine = Routine(**config) + logger.info("watch-routine: loaded new routine %r", routine.name) + + # Reuse the CLI loader (handles routine view + auto-run) + self.load_routine_from_cli(routine, self._watch_auto_run) + except Exception as exc: + logger.error("watch-routine: reload failed: %s", exc, exc_info=True) + def init_ui(self): logger.info("Initializing UI for BadgerHomePage.") self.config_singleton = init_settings() diff --git a/src/badger/gui/windows/main_window.py b/src/badger/gui/windows/main_window.py index 787a237b..f425f329 100644 --- a/src/badger/gui/windows/main_window.py +++ b/src/badger/gui/windows/main_window.py @@ -12,7 +12,7 @@ class BadgerMainWindow(QMainWindow): - def __init__(self, routine=None, auto_run=False) -> None: + def __init__(self, routine=None, auto_run=False, watch_routine=None) -> None: logger.info("Initializing BadgerMainWindow.") super().__init__() self.thread_list = [] @@ -21,6 +21,7 @@ def __init__(self, routine=None, auto_run=False) -> None: self.addSubprocess() self.routine = routine self.auto_run = auto_run + self.watch_routine = watch_routine self.init_ui() self.config_logic() @@ -86,7 +87,8 @@ def init_ui(self) -> None: self.home_page = BadgerHomePage( self.process_manager, routine=self.routine, - auto_run=self.auto_run + auto_run=self.auto_run, + watch_routine=self.watch_routine, ) self.stacks = stacks = QStackedWidget() From 6f203130feccc1da02280a1e3a37173fc82e49f0 Mon Sep 17 00:00:00 2001 From: Zhe Zhang Date: Fri, 15 May 2026 07:56:31 -0700 Subject: [PATCH 09/12] Fix small issues in remote control/monitor --- src/badger/__main__.py | 13 ++++ src/badger/actions/run.py | 16 ++++- src/badger/gui/__init__.py | 16 ++++- src/badger/gui/pages/home_page.py | 100 ++++++++++++++++++++++++++ src/badger/gui/windows/main_window.py | 10 ++- 5 files changed, 150 insertions(+), 5 deletions(-) diff --git a/src/badger/__main__.py b/src/badger/__main__.py index d96e91ca..f9488167 100644 --- a/src/badger/__main__.py +++ b/src/badger/__main__.py @@ -134,6 +134,19 @@ def main(): "was set) restarts. GUI mode only." ), ) + parser_run.add_argument( + "--watch-stop", + type=str, + default=None, + help=( + "Path to a sentinel file the GUI should watch. When the " + "file appears (or is touched), the GUI gracefully stops " + "the currently-running routine WITHOUT closing the window, " + "then deletes the sentinel. Pair with --watch-routine so an " + "external agent can stop runs and swap routines without " + "respawning the GUI. GUI mode only." + ), + ) parser_run.set_defaults(func=run_routine_cli) # Parser for the 'config' command diff --git a/src/badger/actions/run.py b/src/badger/actions/run.py index 306380e5..ba6d302a 100644 --- a/src/badger/actions/run.py +++ b/src/badger/actions/run.py @@ -167,7 +167,9 @@ def run_routine(args): # run_n_archive(routine, args.yes, args.save, args.verbose) -def run_routine_gui(routine, auto_run=False, watch_routine=None): +def run_routine_gui( + routine, auto_run=False, watch_routine=None, watch_stop=None +): """ Launch Badger GUI with pre-loaded routine. @@ -177,9 +179,17 @@ def run_routine_gui(routine, auto_run=False, watch_routine=None): watch_routine: Optional path to a YAML file the GUI should watch for changes; on file modification the GUI stops any active run, reloads, and re-starts (auto_run=True only). + watch_stop: Optional path to a sentinel file the GUI should watch + for existence; when it appears, the GUI gracefully stops the + active run (keeping the window alive) and deletes the file. """ from badger.gui import launch_gui - launch_gui(routine=routine, auto_run=auto_run, watch_routine=watch_routine) + launch_gui( + routine=routine, + auto_run=auto_run, + watch_routine=watch_routine, + watch_stop=watch_stop, + ) def run_routine_headless(routine, auto_run=False, verbose=2): @@ -398,10 +408,12 @@ def run_routine_cli(args): else: # GUI mode (default) watch_routine = getattr(args, "watch_routine", None) + watch_stop = getattr(args, "watch_stop", None) run_routine_gui( routine, auto_run=args.auto_run, watch_routine=watch_routine, + watch_stop=watch_stop, ) except Exception as e: diff --git a/src/badger/gui/__init__.py b/src/badger/gui/__init__.py index 95b587a8..8cbfd23d 100644 --- a/src/badger/gui/__init__.py +++ b/src/badger/gui/__init__.py @@ -81,7 +81,13 @@ def error_handler( raise BadgerError(error_title, error_msg) -def launch_gui(config_path=None, routine=None, auto_run=False, watch_routine=None): +def launch_gui( + config_path=None, + routine=None, + auto_run=False, + watch_routine=None, + watch_stop=None, +): """ Launch the Badger GUI. @@ -93,6 +99,9 @@ def launch_gui(config_path=None, routine=None, auto_run=False, watch_routine=Non watch; on file modification, the GUI stops any active run and reloads + (auto-)restarts. Used by external agents running a multi-stage tuning campaign. + watch_stop: Optional path to a sentinel file the GUI should + watch; when present, the GUI gracefully stops the active + run (window stays open) and deletes the sentinel. """ sys.excepthook = error_handler @@ -125,7 +134,10 @@ def launch_gui(config_path=None, routine=None, auto_run=False, watch_routine=Non # Show the main window window = BadgerMainWindow( - routine=routine, auto_run=auto_run, watch_routine=watch_routine + routine=routine, + auto_run=auto_run, + watch_routine=watch_routine, + watch_stop=watch_stop, ) # Enable Ctrl + C quit diff --git a/src/badger/gui/pages/home_page.py b/src/badger/gui/pages/home_page.py index 33c2038b..cc6fb871 100644 --- a/src/badger/gui/pages/home_page.py +++ b/src/badger/gui/pages/home_page.py @@ -79,6 +79,7 @@ def __init__( routine=None, auto_run=False, watch_routine=None, + watch_stop=None, ): logger.info("Initializing BadgerHomePage.") super().__init__() @@ -90,8 +91,10 @@ def __init__( self.go_run_failed = False # flag to indicate go_run failed # Watch-routine state (campaign-mode reload trigger) self._watch_routine_path = watch_routine + self._watch_stop_path = watch_stop self._watch_auto_run = auto_run self._watch_fs_watcher = None + self._watch_stop_fs_watcher = None self.init_ui() self.config_logic() @@ -108,6 +111,11 @@ def __init__( from PyQt5.QtCore import QTimer QTimer.singleShot(200, self._install_routine_watcher) + # Install file-system watcher for campaign-mode stop sentinel + if self._watch_stop_path: + from PyQt5.QtCore import QTimer + QTimer.singleShot(250, self._install_stop_watcher) + def _install_routine_watcher(self): """ Install a QFileSystemWatcher on `self._watch_routine_path`. When @@ -180,6 +188,39 @@ def _do_watch_reload(self, path): except Exception as exc: logger.warning("watch-routine: stop emit failed: %s", exc) + # Defensive cleanup of leftover state from the prior run. + # `routine_finished` does NOT reset routine_runner — its call + # is intentionally commented out in run_monitor.py — so when + # we reload mid-session, the old subprocess wrapper lingers. + # Disconnect its signals and drop the reference so the next + # `run_monitor.start()` can wire fresh signal connections + # without colliding with the dead routine_runner's slots. + try: + rm = self.run_monitor + if rm is not None and getattr(rm, "routine_runner", None) is not None: + for sig in (getattr(rm, "sig_pause", None), + getattr(rm, "sig_stop", None)): + if sig is not None: + try: + sig.disconnect() + except Exception: + pass # already disconnected + rm.routine_runner = None + rm.running = False + except Exception as exc: + logger.warning("watch-routine: stale runner cleanup failed: %s", exc) + + # Make sure the watcher is still watching `path` — Qt sometimes + # drops files from QFileSystemWatcher after one event when the + # inode briefly disappeared during a write. + try: + if (self._watch_fs_watcher is not None + and path not in self._watch_fs_watcher.files() + and os.path.isfile(path)): + self._watch_fs_watcher.addPath(path) + except Exception: + pass + # Load the new routine from badger.utils import load_template_smart from badger.routine import Routine @@ -192,6 +233,65 @@ def _do_watch_reload(self, path): except Exception as exc: logger.error("watch-routine: reload failed: %s", exc, exc_info=True) + def _install_stop_watcher(self): + """ + Install a QFileSystemWatcher on `self._watch_stop_path`. When the + sentinel file appears (or is modified), emit sig_stop — which is + exactly what clicking the Stop button does. This stops the + optimization subprocess via the multiprocessing stop_event, lets + the run_monitor handle its normal `finished` signal flow (so the + UI updates to "Stopped"), and keeps the GUI window open ready + for the next routine. + """ + from PyQt5.QtCore import QFileSystemWatcher + path = self._watch_stop_path + parent = os.path.dirname(path) or "." + # Watch the parent dir so the sentinel can appear from nothing + # and we still notice (QFileSystemWatcher can't watch a path + # that doesn't exist yet). + self._watch_stop_fs_watcher = QFileSystemWatcher([], self) + # Connect handlers ONCE; addPath later as needed. + self._watch_stop_fs_watcher.fileChanged.connect(self._on_stop_signal) + self._watch_stop_fs_watcher.directoryChanged.connect(self._on_stop_dir_changed) + if os.path.isfile(path): + self._watch_stop_fs_watcher.addPath(path) + if os.path.isdir(parent): + self._watch_stop_fs_watcher.addPath(parent) + logger.info("watch-stop: sentinel = %s", path) + + def _on_stop_dir_changed(self, _changed_dir): + path = self._watch_stop_path + if path and os.path.isfile(path): + if path not in self._watch_stop_fs_watcher.files(): + self._watch_stop_fs_watcher.addPath(path) + # Sentinel appeared — fire the stop handler. + self._on_stop_signal(path) + + def _on_stop_signal(self, path): + """Sentinel file detected: emit sig_stop, then delete the file.""" + try: + if not os.path.isfile(path): + return # already cleaned up by a parallel event + try: + if ( + self.run_monitor is not None + and getattr(self.run_monitor, "running", False) + ): + logger.info("watch-stop: gracefully stopping current run") + self.run_monitor.sig_stop.emit() + else: + logger.info("watch-stop: no active run; nothing to stop") + except Exception as exc: + logger.warning("watch-stop: stop emit failed: %s", exc) + try: + os.remove(path) + except FileNotFoundError: + pass + except Exception as exc: + logger.warning("watch-stop: sentinel delete failed: %s", exc) + except Exception as exc: + logger.error("watch-stop: handler error: %s", exc, exc_info=True) + def init_ui(self): logger.info("Initializing UI for BadgerHomePage.") self.config_singleton = init_settings() diff --git a/src/badger/gui/windows/main_window.py b/src/badger/gui/windows/main_window.py index f425f329..77234d2b 100644 --- a/src/badger/gui/windows/main_window.py +++ b/src/badger/gui/windows/main_window.py @@ -12,7 +12,13 @@ class BadgerMainWindow(QMainWindow): - def __init__(self, routine=None, auto_run=False, watch_routine=None) -> None: + def __init__( + self, + routine=None, + auto_run=False, + watch_routine=None, + watch_stop=None, + ) -> None: logger.info("Initializing BadgerMainWindow.") super().__init__() self.thread_list = [] @@ -22,6 +28,7 @@ def __init__(self, routine=None, auto_run=False, watch_routine=None) -> None: self.routine = routine self.auto_run = auto_run self.watch_routine = watch_routine + self.watch_stop = watch_stop self.init_ui() self.config_logic() @@ -89,6 +96,7 @@ def init_ui(self) -> None: routine=self.routine, auto_run=self.auto_run, watch_routine=self.watch_routine, + watch_stop=self.watch_stop, ) self.stacks = stacks = QStackedWidget() From a965f303a6f55d1443285cd45168b4d90c598185 Mon Sep 17 00:00:00 2001 From: Zhe Zhang Date: Tue, 7 Jul 2026 12:31:26 -0700 Subject: [PATCH 10/12] Adapt to xopt 3.1 while keeping backward-compatibility --- src/badger/archive.py | 8 +++++--- src/badger/core_subprocess.py | 5 +++++ src/badger/gui/components/routine_page.py | 11 ++++++++++- src/badger/logbook.py | 4 +++- src/badger/routine.py | 11 ++++++++++- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/badger/archive.py b/src/badger/archive.py index e9d39e6e..454d9b3e 100644 --- a/src/badger/archive.py +++ b/src/badger/archive.py @@ -35,9 +35,11 @@ def archive_run(routine, states=None): data = routine.sorted_data data_dict = data.to_dict("list") - if hasattr(routine, "creation_ts"): - suffix = routine.creation_ts - else: # compatibility with old routines + # creation_ts is an Optional[str] field on Routine, so hasattr() is + # always True — routines built from YAML templates (badger run / + # agent-proposed drafts) carry None and need the timestamp fallback. + suffix = getattr(routine, "creation_ts", None) + if not suffix: # old routines or template-built routines ts_float = data_dict["timestamp"][0] # time of the first evaluated point suffix = ts_float_to_str(ts_float, "lcls-fname") tokens = suffix.split("-") diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index 7912fcec..a1973331 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -82,6 +82,11 @@ def evaluate_measurement_with_retry( error_title = f"{type(e).__name__}: {e}" error_traceback = traceback.format_exc() logger.error(f"Measurement failed: {error_title}\n{error_traceback}") + if dialog_action_queue is None: + # Headless/CLI runs have no retry dialog to respond to, so + # surface the real measurement error instead of blocking on a + # queue that will never receive a retry/abort action. + raise queue.put( { "type": MEASUREMENT_ERROR_TYPE, diff --git a/src/badger/gui/components/routine_page.py b/src/badger/gui/components/routine_page.py index 86c8ac43..5fe6e03d 100644 --- a/src/badger/gui/components/routine_page.py +++ b/src/badger/gui/components/routine_page.py @@ -48,7 +48,16 @@ all_generator_names, get_generator_dynamic, ) -from xopt.utils import get_local_region +try: + # xopt >= 3.1.2 moved get_local_region to xopt.vocs and swapped its arg order + # to (vocs, center_point, fraction); wrap to preserve our (center_point, vocs, + # fraction) call convention. + from xopt.vocs import get_local_region as _get_local_region + + def get_local_region(center_point, vocs, fraction=0.1): + return _get_local_region(vocs, center_point, fraction) +except ImportError: # older xopt + from xopt.utils import get_local_region from gest_api.vocs import ( BaseConstraint, BaseObjective, diff --git a/src/badger/logbook.py b/src/badger/logbook.py index b46477d6..a36ec61e 100644 --- a/src/badger/logbook.py +++ b/src/badger/logbook.py @@ -6,6 +6,8 @@ from datetime import datetime import logging +from xopt.vocs import select_best + from badger.settings import init_settings from badger.archive import BADGER_ARCHIVE_ROOT from badger.errors import BadgerConfigError, BadgerLogbookError @@ -33,7 +35,7 @@ def send_to_logbook(routine, widget=None): obj_name = routine.vocs.objective_names[0] env_name = routine.environment.name - idx_opt, obj_opt, _ = routine.vocs.select_best(routine.sorted_data, n=1) + idx_opt, obj_opt, _ = select_best(routine.vocs, routine.sorted_data, n=1) idx_opt = int(idx_opt[0]) obj_opt = obj_opt[0] diff --git a/src/badger/routine.py b/src/badger/routine.py index f6e29942..45202111 100644 --- a/src/badger/routine.py +++ b/src/badger/routine.py @@ -28,7 +28,16 @@ ) from xopt import Evaluator, VOCS, Xopt from xopt.generators import get_generator -from xopt.utils import get_local_region +try: + # xopt >= 3.1.2 moved get_local_region to xopt.vocs and swapped its arg order + # to (vocs, center_point, fraction); wrap to preserve our (center_point, vocs, + # fraction) call convention. + from xopt.vocs import get_local_region as _get_local_region + + def get_local_region(center_point, vocs, fraction=0.1): + return _get_local_region(vocs, center_point, fraction) +except ImportError: # older xopt + from xopt.utils import get_local_region from xopt.generators.sequential import SequentialGenerator from badger.utils import curr_ts from badger.environment import BaseEnvironment, instantiate_env From 4b86b62b19ae911a27a35b0f3bb7c718e6c18199 Mon Sep 17 00:00:00 2001 From: nstelter Date: Fri, 4 Sep 2026 07:15:15 -0700 Subject: [PATCH 11/12] fix pre-commit complaints --- src/badger/actions/run.py | 35 ++++++++++++---------- src/badger/gui/pages/home_page.py | 49 ++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/src/badger/actions/run.py b/src/badger/actions/run.py index 8d46471c..22439941 100644 --- a/src/badger/actions/run.py +++ b/src/badger/actions/run.py @@ -177,9 +177,7 @@ def run_routine(args): # run_n_archive(routine, args.yes, args.save, args.verbose) -def run_routine_gui( - routine, auto_run=False, watch_routine=None, watch_stop=None -): +def run_routine_gui(routine, auto_run=False, watch_routine=None, watch_stop=None): """ Launch Badger GUI with pre-loaded routine. @@ -194,6 +192,7 @@ def run_routine_gui( active run (keeping the window alive) and deletes the file. """ from badger.gui import launch_gui + launch_gui( routine=routine, auto_run=auto_run, @@ -217,7 +216,7 @@ def run_routine_headless(routine, auto_run=False, verbose=2): from badger.settings import init_settings # Display routine summary - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f"Routine: {routine.name}") print(f"Environment: {routine.environment.name}") print(f"Generator: {routine.generator.name}") @@ -225,13 +224,13 @@ def run_routine_headless(routine, auto_run=False, verbose=2): print(f"Objectives: {list(routine.vocs.objectives.keys())}") if routine.vocs.constraints: print(f"Constraints: {list(routine.vocs.constraints.keys())}") - print(f"{'='*60}\n") + print(f"{'=' * 60}\n") # Ask for confirmation if not auto_run if not auto_run: try: response = input("Start optimization? [y/N]: ") - if response.lower() != 'y': + if response.lower() != "y": print("Cancelled.") return except (EOFError, KeyboardInterrupt): @@ -257,7 +256,7 @@ def run_routine_headless(routine, auto_run=False, verbose=2): pause_event, wait_event, config_path, - ) + ), ) process.start() @@ -287,7 +286,7 @@ def run_routine_headless(routine, auto_run=False, verbose=2): # Prepare arguments to send to subprocess arg_dict = { - "routine_id": routine.id if hasattr(routine, 'id') else None, + "routine_id": routine.id if hasattr(routine, "id") else None, "routine_filename": routine_filename, "routine_name": routine.name, "variable_ranges": routine.vocs.variables, @@ -306,12 +305,12 @@ def run_routine_headless(routine, auto_run=False, verbose=2): # Signal subprocess to begin execution pause_event.set() # Start unpaused - wait_event.set() # Signal subprocess to begin + wait_event.set() # Signal subprocess to begin # Monitor progress with pause/resume support using signal handler (like old run_n_archive) print("Optimization started. Press Ctrl+C to pause.\n") iteration = 0 - last_data = None + # last_data = None # Storage for signal handler state storage = {"paused": False, "should_exit": False} @@ -340,11 +339,15 @@ def sigint_handler(*args): print("") # new line try: - res = input("Optimization paused. Press Enter to resume or Ctrl+C to terminate: ") + res = input( + "Optimization paused. Press Enter to resume or Ctrl+C to terminate: " + ) while res != "": # Invalid input, ask again sys.stdout.write("\033[F") # Move cursor up to erase line - res = input("Invalid choice. Press Enter to resume or Ctrl+C to terminate: ") + res = input( + "Invalid choice. Press Enter to resume or Ctrl+C to terminate: " + ) except KeyboardInterrupt: # Ctrl+C pressed during input - signal handler already set should_exit=True pass @@ -367,7 +370,7 @@ def sigint_handler(*args): df = results[0] # First element is the data DataFrame if len(df) > iteration: iteration = len(df) - last_data = df + # last_data = df # Check for errors in data_queue if not data_queue.empty(): @@ -390,10 +393,10 @@ def sigint_handler(*args): # Final status elapsed = time.time() - start_time - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f"Optimization completed in {elapsed:.2f}s") print(f"Total iterations: {iteration}") - print(f"{'='*60}\n") + print(f"{'=' * 60}\n") def run_routine_cli(args): @@ -406,6 +409,7 @@ def run_routine_cli(args): try: # Load template using smart detection from badger.utils import load_template_smart + config = load_template_smart(args.template) # Create routine from template @@ -430,5 +434,6 @@ def run_routine_cli(args): logger.error(f"Error running routine: {e}") print(f"Error: {e}") import traceback + traceback.print_exc() sys.exit(1) diff --git a/src/badger/gui/pages/home_page.py b/src/badger/gui/pages/home_page.py index 7cdaadf8..a42bce7c 100644 --- a/src/badger/gui/pages/home_page.py +++ b/src/badger/gui/pages/home_page.py @@ -113,16 +113,21 @@ def __init__( # Auto-load routine from CLI if provided if routine is not None: from PyQt5.QtCore import QTimer - QTimer.singleShot(100, lambda: self.load_routine_from_cli(routine, auto_run)) + + QTimer.singleShot( + 100, lambda: self.load_routine_from_cli(routine, auto_run) + ) # Install file-system watcher for campaign-mode routine swaps if self._watch_routine_path: from PyQt5.QtCore import QTimer + QTimer.singleShot(200, self._install_routine_watcher) # Install file-system watcher for campaign-mode stop sentinel if self._watch_stop_path: from PyQt5.QtCore import QTimer + QTimer.singleShot(250, self._install_stop_watcher) def _install_routine_watcher(self): @@ -133,6 +138,7 @@ def _install_routine_watcher(self): reload the routine, and (if --auto-run was set) restart. """ from PyQt5.QtCore import QFileSystemWatcher + path = self._watch_routine_path if not path or not os.path.isfile(path): logger.warning( @@ -140,15 +146,15 @@ def _install_routine_watcher(self): "watcher will be (re)installed on first change.", path, ) - self._watch_fs_watcher = QFileSystemWatcher([path] if os.path.isfile(path) else [], self) + self._watch_fs_watcher = QFileSystemWatcher( + [path] if os.path.isfile(path) else [], self + ) self._watch_fs_watcher.fileChanged.connect(self._on_watch_routine_changed) # Also watch the parent dir so file-replacement (atomic mv) is caught parent = os.path.dirname(path) or "." if os.path.isdir(parent): self._watch_fs_watcher.addPath(parent) - self._watch_fs_watcher.directoryChanged.connect( - self._on_watch_dir_changed - ) + self._watch_fs_watcher.directoryChanged.connect(self._on_watch_dir_changed) logger.info("watch-routine: watching %s", path) def _on_watch_dir_changed(self, _changed_dir): @@ -170,6 +176,7 @@ def _on_watch_routine_changed(self, path): a thrash. """ from PyQt5.QtCore import QTimer + if not hasattr(self, "_watch_pending"): self._watch_pending = False if self._watch_pending: @@ -188,9 +195,8 @@ def _do_watch_reload(self, path): # Stop any active run, gracefully. try: - if ( - self.run_monitor is not None - and getattr(self.run_monitor, "running", False) + if self.run_monitor is not None and getattr( + self.run_monitor, "running", False ): logger.info("watch-routine: stopping current run before reload") self.run_monitor.sig_stop.emit() @@ -207,8 +213,10 @@ def _do_watch_reload(self, path): try: rm = self.run_monitor if rm is not None and getattr(rm, "routine_runner", None) is not None: - for sig in (getattr(rm, "sig_pause", None), - getattr(rm, "sig_stop", None)): + for sig in ( + getattr(rm, "sig_pause", None), + getattr(rm, "sig_stop", None), + ): if sig is not None: try: sig.disconnect() @@ -223,9 +231,11 @@ def _do_watch_reload(self, path): # drops files from QFileSystemWatcher after one event when the # inode briefly disappeared during a write. try: - if (self._watch_fs_watcher is not None - and path not in self._watch_fs_watcher.files() - and os.path.isfile(path)): + if ( + self._watch_fs_watcher is not None + and path not in self._watch_fs_watcher.files() + and os.path.isfile(path) + ): self._watch_fs_watcher.addPath(path) except Exception: pass @@ -233,6 +243,7 @@ def _do_watch_reload(self, path): # Load the new routine from badger.utils import load_template_smart from badger.routine import Routine + config = load_template_smart(path) routine = Routine(**config) logger.info("watch-routine: loaded new routine %r", routine.name) @@ -253,6 +264,7 @@ def _install_stop_watcher(self): for the next routine. """ from PyQt5.QtCore import QFileSystemWatcher + path = self._watch_stop_path parent = os.path.dirname(path) or "." # Watch the parent dir so the sentinel can appear from nothing @@ -282,9 +294,8 @@ def _on_stop_signal(self, path): if not os.path.isfile(path): return # already cleaned up by a parallel event try: - if ( - self.run_monitor is not None - and getattr(self.run_monitor, "running", False) + if self.run_monitor is not None and getattr( + self.run_monitor, "running", False ): logger.info("watch-stop: gracefully stopping current run") self.run_monitor.sig_stop.emit() @@ -873,7 +884,10 @@ def load_routine_from_cli(self, routine, auto_run): # Populate initial points table based on actions (like "Load Template" does) # This ensures actions like "add_curr" and "add_rand" are executed - if hasattr(self.routine_editor, 'init_table_actions') and self.routine_editor.init_table_actions: + if ( + hasattr(self.routine_editor, "init_table_actions") + and self.routine_editor.init_table_actions + ): self.routine_editor.clear_init_table(reset_actions=False) self.routine_editor.update_init_table(force=True) @@ -890,4 +904,5 @@ def load_routine_from_cli(self, routine, auto_run): # If auto-run requested, start optimization after short delay if auto_run: from PyQt5.QtCore import QTimer + QTimer.singleShot(500, self.start_run) From 2dfd2b3af0f35ea305f2926e0fcdd5fac81b72c6 Mon Sep 17 00:00:00 2001 From: nstelter Date: Fri, 4 Sep 2026 07:29:05 -0700 Subject: [PATCH 12/12] fix gh-actions conda failure --- .github/workflows/tests.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 61928441..5d1cc0c8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,13 +28,12 @@ jobs: DISPLAY: ':99.0' QT_MAC_WANTS_LAYER: 1 # PyQT gui tests involving qtbot interaction on macOS will fail without this steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Miniforge - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest - mamba-version: "2.0.5" activate-environment: badger-dev python-version: ${{ matrix.python-version }} @@ -63,11 +62,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 # timeout should apply to each test run, so get 30 min for each of 3.10, 3.11, 3.12 python runs steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Python 3.12 - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.12" - name: Install pypa/build