Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 35 additions & 17 deletions src/badger/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
262 changes: 262 additions & 0 deletions src/badger/actions/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
8 changes: 5 additions & 3 deletions src/badger/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("-")
Expand Down
Loading
Loading