diff --git a/src/badger/__main__.py b/src/badger/__main__.py index f1d23c88..53ffc0f0 100644 --- a/src/badger/__main__.py +++ b/src/badger/__main__.py @@ -12,6 +12,7 @@ from badger.actions.install import plugin_install from badger.actions.uninstall import plugin_remove from badger.actions.intf import show_intf +from badger.actions.run import run_routine_cli from badger.actions.config import config_settings from badger.log import setup_logging @@ -113,34 +114,51 @@ 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 = subparsers.add_parser( + "run", help="Run optimization from template (YAML file or string)" ) - parser_run.add_argument("-e", "--env", required=True, help="environment to use") + parser_run.add_argument("template", help="YAML template (string or file path)") parser_run.add_argument( - "-ep", "--env_params", help="parameters for the environment" + "--gui", + action="store_true", + help="Launch GUI mode (default)", ) parser_run.add_argument( - "-c", "--config", required=True, help="config for the routine" + "--headless", + action="store_true", + help="Run in headless mode without GUI", ) parser_run.add_argument( - "-s", "--save", nargs="?", const="", help="the routine name to be saved" + "--auto-run", + action="store_true", + help="Auto-start optimization without confirmation", ) parser_run.add_argument( - "-y", "--yes", action="store_true", help="run the routine without confirmation" + "--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.add_argument( - "-v", - "--verbose", - type=int, - choices=[0, 1, 2], - default=2, - const=2, - nargs="?", - help="verbose level of optimization progress", + "--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 parser_config = subparsers.add_parser("config", help="Badger configurations") diff --git a/src/badger/actions/run.py b/src/badger/actions/run.py index 0617e3c9..22439941 100644 --- a/src/badger/actions/run.py +++ b/src/badger/actions/run.py @@ -175,3 +175,265 @@ 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): + """ + 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). + 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, + watch_stop=watch_stop, + ) + + +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, Pipe + from badger.core_subprocess import run_routine_subprocess + from badger.archive import save_tmp_run + from badger.settings import init_settings + + # 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 (matching GUI architecture) + # CRITICAL: Must create these BEFORE starting subprocess with 'spawn' + data_queue = Queue() + evaluate_queue = Pipe() + stop_event = Event() + pause_event = Event() + wait_event = Event() + config_path = init_settings()._instance.config_path + + # Start subprocess FIRST (matching GUI pattern) + process = Process( + target=run_routine_subprocess, + args=( + data_queue, + evaluate_queue, + stop_event, + pause_event, + 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, + "run_data": False, + "init_points": True, + } + + # 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 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 + + # Storage for signal handler state + storage = {"paused": False, "should_exit": False} + + def sigint_handler(*args): + """Signal handler for Ctrl+C - sets pause flag or raises to exit""" + if storage["paused"]: + # 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 + + # 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 + + 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"]: + 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) + 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") + + +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) + 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: + logger.error(f"Error running routine: {e}") + print(f"Error: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) 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 be94c6bd..a1973331 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -11,7 +11,9 @@ """ from copy import deepcopy +import json import logging +import signal import time import traceback from typing import Any @@ -43,6 +45,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 evaluate_measurement_with_retry( routine: Routine, point: Any, @@ -57,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, @@ -187,12 +217,16 @@ 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) + logger.info("Waiting for wait_event to be set...") wait_event.wait() args: dict[str, Any] = {} try: - args = queue.get(timeout=1) + args = queue.get(timeout=5) logger.debug(f"Received args from queue: {args}") except Exception as e: logger.error(f"Error in subprocess queue.get: {type(e).__name__}, {str(e)}") @@ -291,6 +325,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: @@ -359,6 +394,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: @@ -369,6 +405,10 @@ def run_routine_subprocess( logger.info("Optimization terminated by 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: logger.error(f"XoptError during optimization: {e}") opt_logger.update(Events.OPTIMIZATION_END, solution_meta) diff --git a/src/badger/db.py b/src/badger/db.py index 635ac767..09669d20 100644 --- a/src/badger/db.py +++ b/src/badger/db.py @@ -28,6 +28,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/__init__.py b/src/badger/gui/__init__.py index 45976b40..d89194e0 100644 --- a/src/badger/gui/__init__.py +++ b/src/badger/gui/__init__.py @@ -84,7 +84,28 @@ 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, + watch_routine=None, + watch_stop=None, +): + """ + 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 + 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. + 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 app = QApplication(sys.argv) @@ -115,7 +136,12 @@ def launch_gui(config_path=None): app.setStyleSheet("") # Show the main window - window = BadgerMainWindow() + window = BadgerMainWindow( + routine=routine, + auto_run=auto_run, + watch_routine=watch_routine, + watch_stop=watch_stop, + ) # Enable Ctrl + C quit signal.signal(signal.SIGINT, on_exit) diff --git a/src/badger/gui/components/pydantic_editor.py b/src/badger/gui/components/pydantic_editor.py index 1a7f49e1..40466540 100644 --- a/src/badger/gui/components/pydantic_editor.py +++ b/src/badger/gui/components/pydantic_editor.py @@ -217,11 +217,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: QWidget | None = None property_name = editor_info[1].text(0) if editor_info is not None else "" @@ -267,7 +270,7 @@ def resolve_qt( f"Property name {property_name}: 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(): @@ -296,7 +299,7 @@ def resolve_qt( f"Property name {property_name}: 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): @@ -579,8 +582,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) diff --git a/src/badger/gui/components/routine_page.py b/src/badger/gui/components/routine_page.py index 7d065139..621bd5d6 100644 --- a/src/badger/gui/components/routine_page.py +++ b/src/badger/gui/components/routine_page.py @@ -38,6 +38,24 @@ import pandas as pd import yaml from coolname import generate_slug +from xopt import VOCS +from xopt.vocs import random_inputs +from xopt.generators import ( + get_generator_defaults, + all_generator_names, + get_generator_dynamic, +) + +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/gui/pages/home_page.py b/src/badger/gui/pages/home_page.py index bfa3e0b4..12b688fb 100644 --- a/src/badger/gui/pages/home_page.py +++ b/src/badger/gui/pages/home_page.py @@ -88,7 +88,14 @@ class BadgerHomePage(QWidget): sig_routine_activated = pyqtSignal(bool) sig_routine_invalid = pyqtSignal() - def __init__(self, process_manager: "Optional[ProcessManager]" = None): + def __init__( + self, + process_manager=None, + routine=None, + auto_run=False, + watch_routine=None, + watch_stop=None, + ): logger.info("Initializing BadgerHomePage.") super().__init__() @@ -97,12 +104,220 @@ def __init__(self, process_manager: "Optional[ProcessManager]" = None): 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_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() 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) + ) + + # 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): + """ + 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) + + # 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 + + 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 _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) -> None: logger.info("Initializing UI for BadgerHomePage.") self.config_singleton = init_settings() @@ -671,3 +886,42 @@ def uncover_page(self) -> None: 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) + + # 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 + + # 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/windows/main_window.py b/src/badger/gui/windows/main_window.py index 6b92fd73..18ad4cbe 100644 --- a/src/badger/gui/windows/main_window.py +++ b/src/badger/gui/windows/main_window.py @@ -15,13 +15,23 @@ class BadgerMainWindow(QMainWindow): - def __init__(self) -> 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 = [] self.process_manager = ProcessManager() self.process_manager.processQueueUpdated.connect(self.addSubprocess) self.addSubprocess() + self.routine = routine + self.auto_run = auto_run + self.watch_routine = watch_routine + self.watch_stop = watch_stop self.init_ui() self.config_logic() @@ -84,7 +94,13 @@ 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, + watch_routine=self.watch_routine, + watch_stop=self.watch_stop, + ) self.stacks = stacks = QStackedWidget() stacks.addWidget(self.home_page) 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 0ab501ec..a4c083b8 100644 --- a/src/badger/routine.py +++ b/src/badger/routine.py @@ -28,7 +28,17 @@ ) from xopt import Evaluator, VOCS, Xopt from xopt.generators import get_generator -from xopt.vocs 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 diff --git a/src/badger/utils.py b/src/badger/utils.py index bcc6d7da..85575812 100644 --- a/src/badger/utils.py +++ b/src/badger/utils.py @@ -126,6 +126,63 @@ 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