From 61a06afccc673a738ad952b6d54cadb73e501dca Mon Sep 17 00:00:00 2001 From: michaellans Date: Thu, 6 Aug 2026 10:20:54 -0700 Subject: [PATCH 01/36] set run_until as default behavior for mini --- src/badger/gui/mini/pages/home_page.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index e9b7989b..629168e1 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -283,6 +283,18 @@ def config_logic(self): self.sig_routine_invalid.connect(self.run_action_bar.routine_invalid) + self._configure_default_run_action() + + def _configure_default_run_action(self): + """Set the default run action as run_until_action""" + self.run_action_bar.btn_stop.setDefaultAction( + self.run_action_bar.run_until_action + ) + # configure default to max_eval (tc_idx=0), 50 iterations + self.run_monitor.save_termination_condition( + {"tc_idx": 0, "max_eval": 50, "max_time": 300, "ftol": 0} + ) + def update_saved_values_from_monitor(self): """ Sync Saved column values to match run monitor reset_env targets. From 9a9cfce9a7835e88514549f5dc5183208de3f633 Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 11 Aug 2026 16:42:03 -0700 Subject: [PATCH 02/36] add termination_reached_dialog --- .../gui/windows/termination_reached_dialog.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/badger/gui/windows/termination_reached_dialog.py diff --git a/src/badger/gui/windows/termination_reached_dialog.py b/src/badger/gui/windows/termination_reached_dialog.py new file mode 100644 index 00000000..af5d76b6 --- /dev/null +++ b/src/badger/gui/windows/termination_reached_dialog.py @@ -0,0 +1,110 @@ +"""Dialog shown when a run-until threshold is reached during optimization. + +Lets users choose whether to continue running or end the current run, +after the run is paused by a termination condition. +""" + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QDialog, + QDialogButtonBox, + QHBoxLayout, + QLabel, + QVBoxLayout, +) + +stylesheet_run = """ +QPushButton:hover:pressed +{ + background-color: #92D38C; +} +QPushButton:hover +{ + background-color: #6EC566; +} +QPushButton +{ + background-color: #4AB640; + color: #000000; +} +""" + +stylesheet_stop = """ +QPushButton:hover:pressed +{ + background-color: #C7737B; +} +QPushButton:hover +{ + background-color: #BF616A; +} +QPushButton +{ + background-color: #A9444E; +} +""" + + +class BadgerTerminationReachedDialog(QDialog): + def __init__(self, tc_condition=None, text="", parent=None): + super().__init__(parent) + + self.setWindowTitle("Termination Condition Reached") + self.setMinimumWidth(360) + + layout = QVBoxLayout(self) + layout.setContentsMargins(14, 14, 14, 14) + layout.setSpacing(8) + + tc_type = tc_condition["type"] + if tc_type == "max_eval": + tc_type_text = "Max evaluation" + state = tc_condition["state"] + else: + tc_type_text = "Timeout" + state = f"{tc_condition['state']:.2f} s" + + content_row = QHBoxLayout() + content_row.setSpacing(6) + + text_column = QVBoxLayout() + text_column.setSpacing(3) + + title_label = QLabel("Termination condition reached") + title_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + title_label.setStyleSheet("font-size: 14px; font-weight: 600;") + text_column.addWidget(title_label) + + body_label = QLabel("Badger optimization stopped.") + body_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + text_column.addWidget(body_label) + + summary_label = QLabel(f"{tc_type_text}: {state}/{tc_condition['config']}") + summary_label.setWordWrap(True) + summary_label.setAlignment(Qt.AlignLeft) + summary_label.setStyleSheet("color: #8A949E;") + text_column.addWidget(summary_label) + + content_row.addLayout(text_column) + layout.addLayout(content_row) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.continueButton = button_box.button(QDialogButtonBox.Ok) + self.endButton = button_box.button(QDialogButtonBox.Cancel) + + font = self.font() + font.setPointSize(12) + self.setFont(font) + + self.continueButton.setText("Continue") + # self.continueButton.setStyleSheet(stylesheet_run) + self.continueButton.setFixedSize(96, 24) + self.endButton.setText("End Run") + self.endButton.setStyleSheet(stylesheet_stop) + self.endButton.setFixedSize(96, 24) + + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + self.resize(360, 150) From 6d5b1741a8a13d9b017a80e4f112abd458aaedfa Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 11 Aug 2026 16:59:11 -0700 Subject: [PATCH 03/36] reaching termination condition in optimization loop pauses and opens dialog --- src/badger/core_subprocess.py | 83 ++++++++++++++++++++- src/badger/errors.py | 7 ++ src/badger/gui/components/routine_runner.py | 28 +++++++ 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index be94c6bd..ea2cf3a9 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -31,6 +31,10 @@ MEASUREMENT_ACTION_TYPE, MEASUREMENT_ACTION_RETRY, MEASUREMENT_ACTION_ABORT, + TERMINATION_REACHED_TYPE, + TERMINATION_ACTION_TYPE, + TERMINATION_ACTION_CONTINUE, + TERMINATION_ACTION_END, ) from badger.logger import _get_default_logger from badger.logger.event import Events @@ -89,6 +93,47 @@ def evaluate_measurement_with_retry( ) +def pause_for_termination_dialog_action( + queue: mp.Queue, + stop_process: mp.Event, + pause_process: mp.Event, + dialog_action_queue: mp.Queue, + tc_condition: dict, +) -> None: + """Pause the run and wait for user action when run-until condition is reached.""" + queue.put( + { + "type": TERMINATION_REACHED_TYPE, + "tc_condition": tc_condition, + } + ) + + while True: + if stop_process.is_set(): + raise BadgerRunTerminated + + try: + msg = dialog_action_queue.get( + timeout=0.1 + ) # short timeout here, so we can make checks for stop_process + except Empty: + continue + + if ( + isinstance(msg, dict) + and msg.get("type") == TERMINATION_ACTION_TYPE + and msg.get("action") + in [TERMINATION_ACTION_CONTINUE, TERMINATION_ACTION_END] + ): + if msg["action"] == TERMINATION_ACTION_CONTINUE: + pause_process.set() + return + + raise BadgerRunTerminated( + "Run terminated after termination condition reached" + ) + + 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. @@ -323,16 +368,46 @@ def run_routine_subprocess( if count >= max_eval: logger.info( - "Max evaluations reached. Terminating optimization." + "Max evaluations reached. Pausing optimization and waiting for user action." ) - raise BadgerRunTerminated + pause_process.clear() + pause_for_termination_dialog_action( + queue=queue, + stop_process=stop_process, + pause_process=pause_process, + dialog_action_queue=dialog_action_queue, + tc_condition={ + "type": "max_eval", + "config": max_eval, + "state": count, + }, + ) + # reset termination condition + termination_condition = None + continue elif idx == 1: max_time = tc_config["max_time"] dt = time.time() - start_time logger.debug(f"Checking max_time termination: {dt} >= {max_time}") if dt >= max_time: - logger.info("Max time reached. Terminating optimization.") - raise BadgerRunTerminated + logger.info( + "Max time reached. Pausing optimization and waiting for user action." + ) + pause_process.clear() + pause_for_termination_dialog_action( + queue=queue, + stop_process=stop_process, + pause_process=pause_process, + dialog_action_queue=dialog_action_queue, + tc_condition={ + "type": "max_time", + "config": max_time, + "state": dt, + }, + ) + # reset termination condition + termination_condition = None + continue candidates = routine.generator.generate(1)[0] logger.debug(f"Generated candidates: {candidates}") diff --git a/src/badger/errors.py b/src/badger/errors.py index fdc42237..02a575d4 100644 --- a/src/badger/errors.py +++ b/src/badger/errors.py @@ -121,3 +121,10 @@ def __init__(self, message="Optimization run has been terminated!"): MEASUREMENT_ACTION_TYPE = "measurement_action" MEASUREMENT_ACTION_RETRY = "retry" MEASUREMENT_ACTION_ABORT = "abort" + +# Constants for run-until termination dialog feature. +# Used in communication between routine runner and subprocess. +TERMINATION_REACHED_TYPE = "termination_reached" +TERMINATION_ACTION_TYPE = "termination_action" +TERMINATION_ACTION_CONTINUE = "continue" +TERMINATION_ACTION_END = "end" diff --git a/src/badger/gui/components/routine_runner.py b/src/badger/gui/components/routine_runner.py index 63e874cd..bd8a01e3 100644 --- a/src/badger/gui/components/routine_runner.py +++ b/src/badger/gui/components/routine_runner.py @@ -22,12 +22,19 @@ MEASUREMENT_ACTION_TYPE, MEASUREMENT_ACTION_RETRY, MEASUREMENT_ACTION_ABORT, + TERMINATION_REACHED_TYPE, + TERMINATION_ACTION_TYPE, + TERMINATION_ACTION_CONTINUE, + TERMINATION_ACTION_END, ) from badger.tests.utils import get_current_vars from badger.routine import calculate_variable_bounds, calculate_initial_points from badger.settings import init_settings from badger.gui.components.process_manager import ProcessManager from badger.gui.windows.measurement_retry_dialog import BadgerMeasurementRetryDialog +from badger.gui.windows.termination_reached_dialog import ( + BadgerTerminationReachedDialog, +) from badger.routine import Routine logger = logging.getLogger(__name__) @@ -261,6 +268,17 @@ def check_queue(self) -> None: "action": action, } ) + elif ( + isinstance(msg, dict) + and msg.get("type") == TERMINATION_REACHED_TYPE + ): + action = self.handle_termination_reached(msg) + self.dialog_action_queue.put( + { + "type": TERMINATION_ACTION_TYPE, + "action": action, + } + ) else: error_title, error_traceback = msg BadgerError(error_title, error_traceback) @@ -281,6 +299,16 @@ def handle_measurement_error(self, msg: dict) -> str: return MEASUREMENT_ACTION_RETRY return MEASUREMENT_ACTION_ABORT + def handle_termination_reached(self, msg: dict) -> str: + dialog = BadgerTerminationReachedDialog( + tc_condition=msg.get("tc_condition"), + text=msg.get("title", "A termination condition has been reached."), + ) + result = dialog.exec_() + if result == QDialog.Accepted: + return TERMINATION_ACTION_CONTINUE + return TERMINATION_ACTION_END + def after_evaluate(self, results: pd.DataFrame) -> None: logger.debug("Received evaluation results from subprocess.") """ From 9434db81f563813497e44c6e69cd40ec5b53f8a4 Mon Sep 17 00:00:00 2001 From: michaellans Date: Wed, 12 Aug 2026 12:17:46 -0700 Subject: [PATCH 04/36] Add logic to skip tc dialog from stop button press, show if selected from menu --- src/badger/gui/components/action_bar.py | 36 +++++++++++++++++++---- src/badger/gui/mini/pages/home_page.py | 38 ++++++++++++++++--------- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/src/badger/gui/components/action_bar.py b/src/badger/gui/components/action_bar.py index 4a0b8c5f..fd56099c 100644 --- a/src/badger/gui/components/action_bar.py +++ b/src/badger/gui/components/action_bar.py @@ -89,7 +89,9 @@ class BadgerActionBar(QWidget): sig_start = pyqtSignal() - sig_start_until = pyqtSignal() + sig_start_until = pyqtSignal( + bool + ) # bool True launches termination condition dialog menu sig_stop = pyqtSignal() sig_delete_run = pyqtSignal() @@ -198,8 +200,14 @@ def load_internal_icon(name: str) -> QIcon: run_action.setIcon(self.icon_play) self.run_until_action = run_until_action = QAction("Run until", self) run_until_action.setIcon(self.icon_play) + self.run_until_menu_action = run_until_menu_action = QAction("Run until", self) + run_until_menu_action.setIcon(self.icon_play) menu.addAction(run_action) - menu.addAction(run_until_action) + menu.addAction(run_until_menu_action) + # Note: run_until_menu_action is triggered by selecting "run until" from the menu + # It emits sig_start_until(True) to launch the BadgerTerminationConditionDialog + # and sets the default run action to run_until_action. Pressing the play/stop button + # will then emit sig_start_until(False) and skip the dialog popup. # Set the menu as the run button's dropdown menu self.btn_stop.setMenu(menu) @@ -244,8 +252,11 @@ def config_logic(self): self.btn_opt.clicked.connect(self.jump_to_optimal) self.btn_set.clicked.connect(self.dial_in) self.btn_ctrl.clicked.connect(self.ctrl_routine) - self.run_action.triggered.connect(self.set_run_action) - self.run_until_action.triggered.connect(self.set_run_until_action) + self.run_action.triggered.connect(self._on_run_action_triggered) + self.run_until_action.triggered.connect(self._on_run_until_action_triggered) + self.run_until_menu_action.triggered.connect( + self._on_run_until_menu_action_triggered + ) self.save_checkpoint_action.triggered.connect( lambda: self.sig_save_checkpoint.emit() ) @@ -293,6 +304,8 @@ def routine_finished(self): self.run_action.setIcon(self.icon_play) self.run_until_action.setText("Run until") self.run_until_action.setIcon(self.icon_play) + self.run_until_menu_action.setText("Run until") + self.run_until_menu_action.setIcon(self.icon_play) # self.btn_stop.setToolTip('') self.btn_stop.setDisabled(False) @@ -320,6 +333,8 @@ def run_start(self): self.run_action.setIcon(self.icon_stop) self.run_until_action.setText("Stop") self.run_until_action.setIcon(self.icon_stop) + self.run_until_menu_action.setText("Stop") + self.run_until_menu_action.setIcon(self.icon_stop) self.btn_checkpoint.setDisabled(False) self.btn_ctrl.setDisabled(False) self.btn_set.setDisabled(True) @@ -335,16 +350,25 @@ def set_run_action(self): self.btn_stop.setDisabled(True) self.sig_stop.emit() - def set_run_until_action(self): + def set_run_until_action(self, from_menu=False): if self.btn_stop.defaultAction() is not self.run_until_action: self.btn_stop.setDefaultAction(self.run_until_action) if self.run_until_action.text() == "Run until": - self.sig_start_until.emit() + self.sig_start_until.emit(from_menu) else: self.btn_stop.setDisabled(True) self.sig_stop.emit() + def _on_run_action_triggered(self): + self.set_run_action() + + def _on_run_until_action_triggered(self): + self.set_run_until_action(from_menu=False) + + def _on_run_until_menu_action_triggered(self): + self.set_run_until_action(from_menu=True) + def delete_run(self): self.sig_delete_run.emit() diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index 629168e1..7d8acc52 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -485,20 +485,32 @@ def start_run(self, use_termination_condition: bool = False): init_points_flag=True, ) - def start_run_until(self): + def start_run_until(self, dialog: bool = True): + """ + Starts run with termination condition. + + Args: + dialog (bool): If True, opens dialog popup for selecting termination condition. + If False skips dialog and uses the tc_config which + was previously saved in the run_monitor. + + Notes: If no tc_config is found, opens popup regardless of dialog flag. + """ logger.info("Starting run until condition met.") - dlg = BadgerTerminationConditionDialog( - self, - self.start_run, - self.run_monitor.save_termination_condition, - self.run_monitor.termination_condition, - ) - self.tc_dialog = dlg - try: - dlg.exec() - finally: - self.tc_dialog = None - # self.run_monitor.start_until() + if dialog or not self.run_monitor.termination_condition: + dlg = BadgerTerminationConditionDialog( + self, + self.start_run, + self.run_monitor.save_termination_condition, + self.run_monitor.termination_condition, + ) + self.tc_dialog = dlg + try: + dlg.exec() + finally: + self.tc_dialog = None + else: + self.start_run(use_termination_condition=True) def new_run(self): logger.info("Creating new run.") From cc1d5e473048165ada5b2c814bbc18a5f2fcea74 Mon Sep 17 00:00:00 2001 From: michaellans Date: Wed, 12 Aug 2026 17:00:27 -0700 Subject: [PATCH 05/36] Update tooltips for stop/run button to indicate termination condition --- src/badger/gui/components/action_bar.py | 57 +++++++++++++++++++++++-- src/badger/gui/mini/pages/home_page.py | 9 ++-- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/badger/gui/components/action_bar.py b/src/badger/gui/components/action_bar.py index fd56099c..63f5e0b3 100644 --- a/src/badger/gui/components/action_bar.py +++ b/src/badger/gui/components/action_bar.py @@ -1,14 +1,48 @@ """Toolbar with run-control buttons (start, pause, stop), logbook submission, docs access, and the extensions palette launcher.""" -from PyQt5.QtWidgets import QWidget, QHBoxLayout +from PyQt5.QtWidgets import QStyle, QStyleOptionToolButton, QWidget, QHBoxLayout from PyQt5.QtWidgets import QToolButton, QMenu, QAction from PyQt5.QtGui import QIcon, QFont -from PyQt5.QtCore import pyqtSignal, QSize +from PyQt5.QtCore import QEvent, pyqtSignal, QSize from importlib import resources from badger.gui.utils import create_button from badger.gui.windows.docs_window import BadgerDocsWindow + +class SplitTooltipToolButton(QToolButton): + """ + QToolButton that shows a separate tooltip over the dropdown-arrow area. + Use arg menu_tooltip="desired tooltip" to set the menu tooltip + """ + + def __init__(self, menu_tooltip="", parent=None): + """ + Parameters + ---------- + menu_tooltip (str) + tooltip for menu + """ + super().__init__(parent) + self.menu_tooltip = menu_tooltip + + def _over_menu_arrow(self, pos): + opt = QStyleOptionToolButton() + self.initStyleOption(opt) + rect = self.style().subControlRect( + QStyle.CC_ToolButton, opt, QStyle.SC_ToolButtonMenu, self + ) + return rect.contains(pos) + + def event(self, event): + if event.type() == QEvent.ToolTip and self._over_menu_arrow(event.pos()): + from PyQt5.QtWidgets import QToolTip + + QToolTip.showText(event.globalPos(), self.menu_tooltip, self) + return True + return super().event(event) + + stylesheet_del = """ QPushButton:hover:pressed { @@ -162,7 +196,9 @@ def load_internal_icon(name: str) -> QIcon: self.btn_ctrl.setDisabled(True) # self.btn_stop = btn_stop = QPushButton('Run') - self.btn_stop = QToolButton() + self.btn_stop = SplitTooltipToolButton( + menu_tooltip="Update Termination Condition" + ) self.btn_stop.setFixedSize(96, 32) self.btn_stop.setFont(cool_font) self.btn_stop.setStyleSheet(stylesheet_run) @@ -214,7 +250,7 @@ def load_internal_icon(name: str) -> QIcon: self.btn_stop.setDefaultAction(run_action) self.btn_stop.setPopupMode(QToolButton.MenuButtonPopup) self.btn_stop.setDisabled(False) - # btn_stop.setToolTip('') + run_action.setToolTip("Run") # Config button self.btn_config = btn_config = create_button("tools.png", "Configure run") @@ -406,3 +442,16 @@ def open_extensions_palette(self): def env_ready(self): self.btn_log.setDisabled(False) self.btn_opt.setDisabled(False) + + def update_run_tooltip(self, tc=None): + """Update btn_stop tooltip: tc dict for run-until mode, or None.""" + if tc is None: + self.run_action.setToolTip("Run") + else: + tc_idx = tc.get("tc_idx", 0) + if tc_idx == 0: + tip = f"Run until: n iterations = {tc.get('max_eval')}" + elif tc_idx == 1: + tip = f"Run until: timeout = {tc.get('max_time')}s" + self.run_until_action.setToolTip(tip) + self.run_until_menu_action.setToolTip(tip) diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index 7d8acc52..1a8ded55 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -291,9 +291,9 @@ def _configure_default_run_action(self): self.run_action_bar.run_until_action ) # configure default to max_eval (tc_idx=0), 50 iterations - self.run_monitor.save_termination_condition( - {"tc_idx": 0, "max_eval": 50, "max_time": 300, "ftol": 0} - ) + initial_tc = {"tc_idx": 0, "max_eval": 50, "max_time": 300, "ftol": 0} + self.run_monitor.save_termination_condition(initial_tc) + self.run_action_bar.update_run_tooltip(initial_tc) def update_saved_values_from_monitor(self): """ @@ -509,6 +509,9 @@ def start_run_until(self, dialog: bool = True): dlg.exec() finally: self.tc_dialog = None + self.run_action_bar.update_run_tooltip( + self.run_monitor.termination_condition + ) else: self.start_run(use_termination_condition=True) From 7cc2cedb95734ae9118208f2d5a37bb372591f9e Mon Sep 17 00:00:00 2001 From: michaellans Date: Wed, 12 Aug 2026 18:25:38 -0700 Subject: [PATCH 06/36] update status when routine paused, including termination_condition --- src/badger/gui/components/routine_runner.py | 24 +++++++++++++++++++-- src/badger/gui/components/run_monitor.py | 1 + 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/badger/gui/components/routine_runner.py b/src/badger/gui/components/routine_runner.py index bd8a01e3..968cb5cf 100644 --- a/src/badger/gui/components/routine_runner.py +++ b/src/badger/gui/components/routine_runner.py @@ -47,6 +47,7 @@ class BadgerRoutineSignals(QObject): error = pyqtSignal(Exception) info = pyqtSignal(str) states = pyqtSignal(str) + sig_status = pyqtSignal(str) # status message information class BadgerRoutineSubprocess: @@ -300,15 +301,32 @@ def handle_measurement_error(self, msg: dict) -> str: return MEASUREMENT_ACTION_ABORT def handle_termination_reached(self, msg: dict) -> str: + # update status + tc_condition = msg.get("tc_condition") + status_str = self._format_tc_status_str(tc_condition) + self.signals.sig_status.emit(status_str) + + # launch dialog dialog = BadgerTerminationReachedDialog( - tc_condition=msg.get("tc_condition"), - text=msg.get("title", "A termination condition has been reached."), + tc_condition=tc_condition, + text=msg.get("title"), ) result = dialog.exec_() if result == QDialog.Accepted: + self.signals.sig_status.emit(f"Running routine {self.routine.name}...") return TERMINATION_ACTION_CONTINUE return TERMINATION_ACTION_END + def _format_tc_status_str(self, tc_condition: dict) -> str: + tc_type = tc_condition["type"] + if tc_type == "max_eval": + tc_type_text = "N iterations" + state = tc_condition["state"] + else: + tc_type_text = "timeout" + state = f"{tc_condition['state']:.2f} s" + return f"Routine {self.routine.name} paused: Condition {tc_type_text} = {state} reached" + def after_evaluate(self, results: pd.DataFrame) -> None: logger.debug("Received evaluation results from subprocess.") """ @@ -357,8 +375,10 @@ def ctrl_routine(self, pause: bool) -> None: pause : bool """ if pause: + self.signals.sig_status.emit(f"Routine {self.routine.name} paused") self.pause_event.clear() else: + self.signals.sig_status.emit(f"Running routine {self.routine.name}...") self.pause_event.set() def close(self) -> None: diff --git a/src/badger/gui/components/run_monitor.py b/src/badger/gui/components/run_monitor.py index b5e2e7bf..bfba360f 100644 --- a/src/badger/gui/components/run_monitor.py +++ b/src/badger/gui/components/run_monitor.py @@ -454,6 +454,7 @@ def init_routine_runner(self): routine_runner.signals.error.connect(self.on_error) routine_runner.signals.info.connect(self.on_info) routine_runner.signals.states.connect(self.states) + routine_runner.signals.sig_status.connect(self.sig_status.emit) self.sig_pause.connect(routine_runner.ctrl_routine) self.sig_stop.connect(routine_runner.stop_routine) From 82692db034c3b5764f46db5179898c6eb959bb2c Mon Sep 17 00:00:00 2001 From: michaellans Date: Thu, 13 Aug 2026 18:16:18 -0700 Subject: [PATCH 07/36] block signals on history_tree update after run --- src/badger/gui/mini/pages/home_page.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index 1a8ded55..9ce11a71 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -528,8 +528,11 @@ def new_run(self): def run_name(self, name): logger.info(f"Updating run name: {name}") runs = get_runs() + # block signals on update after routine finished, since the selected run is already diplayed + self.history_browser.history_tree_widget.blockSignals(True) self.history_browser.updateItems(runs) self.history_browser._selectItemByRun(name) + self.history_browser.history_tree_widget.blockSignals(False) def update_status(self, info): logger.info(f"Updating status: {info}") From fa8b1f27098e76230b6f6863aaeebe01932d9e8d Mon Sep 17 00:00:00 2001 From: michaellans Date: Mon, 17 Aug 2026 14:11:52 -0700 Subject: [PATCH 08/36] import xopt.generators during intial load (bayesian and sequential) --- src/badger/routine.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/badger/routine.py b/src/badger/routine.py index 0ab501ec..51249944 100644 --- a/src/badger/routine.py +++ b/src/badger/routine.py @@ -34,6 +34,11 @@ from badger.environment import BaseEnvironment, instantiate_env from badger.factory import get_env +# Import xopt.generators at startup so they don't need to be imported +# each time a Routine is created +import xopt.generators.bayesian +import xopt.generators.sequential + logger = logging.getLogger(__name__) From 7a5c645f71b80df80bc336073504dbac08aaefe7 Mon Sep 17 00:00:00 2001 From: michaellans Date: Mon, 17 Aug 2026 14:16:16 -0700 Subject: [PATCH 09/36] add noqa F401 --- src/badger/routine.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/badger/routine.py b/src/badger/routine.py index 51249944..cca84e8e 100644 --- a/src/badger/routine.py +++ b/src/badger/routine.py @@ -36,8 +36,8 @@ # Import xopt.generators at startup so they don't need to be imported # each time a Routine is created -import xopt.generators.bayesian -import xopt.generators.sequential +import xopt.generators.bayesian # noqa: F401 +import xopt.generators.sequential # noqa: F401 logger = logging.getLogger(__name__) From 1ef607880ff9fb6d96461e2f160d02546b618541 Mon Sep 17 00:00:00 2001 From: michaellans Date: Mon, 17 Aug 2026 14:43:46 -0700 Subject: [PATCH 10/36] Add resume option to mini gui and logic to load displayed data into new routine --- src/badger/gui/components/action_bar.py | 11 +- src/badger/gui/mini/pages/home_page.py | 135 ++++++++++++++++++++++-- 2 files changed, 133 insertions(+), 13 deletions(-) diff --git a/src/badger/gui/components/action_bar.py b/src/badger/gui/components/action_bar.py index 63f5e0b3..e39d827f 100644 --- a/src/badger/gui/components/action_bar.py +++ b/src/badger/gui/components/action_bar.py @@ -134,6 +134,7 @@ class BadgerActionBar(QWidget): sig_jump_to_optimal = pyqtSignal() sig_dial_in = pyqtSignal() sig_ctrl = pyqtSignal(bool) + sig_run_with_data = pyqtSignal() sig_open_extensions_palette = pyqtSignal() sig_save_checkpoint = pyqtSignal() @@ -196,9 +197,7 @@ def load_internal_icon(name: str) -> QIcon: self.btn_ctrl.setDisabled(True) # self.btn_stop = btn_stop = QPushButton('Run') - self.btn_stop = SplitTooltipToolButton( - menu_tooltip="Update Termination Condition" - ) + self.btn_stop = SplitTooltipToolButton(menu_tooltip="Run Options Menu") self.btn_stop.setFixedSize(96, 32) self.btn_stop.setFont(cool_font) self.btn_stop.setStyleSheet(stylesheet_run) @@ -238,8 +237,11 @@ def load_internal_icon(name: str) -> QIcon: run_until_action.setIcon(self.icon_play) self.run_until_menu_action = run_until_menu_action = QAction("Run until", self) run_until_menu_action.setIcon(self.icon_play) + self.run_with_data_action = run_with_data_action = QAction("Resume", self) + run_with_data_action.setIcon(self.icon_play) menu.addAction(run_action) menu.addAction(run_until_menu_action) + menu.addAction(run_with_data_action) # Note: run_until_menu_action is triggered by selecting "run until" from the menu # It emits sig_start_until(True) to launch the BadgerTerminationConditionDialog # and sets the default run action to run_until_action. Pressing the play/stop button @@ -293,6 +295,9 @@ def config_logic(self): self.run_until_menu_action.triggered.connect( self._on_run_until_menu_action_triggered ) + self.run_with_data_action.triggered.connect( + lambda: self.sig_run_with_data.emit() + ) self.save_checkpoint_action.triggered.connect( lambda: self.sig_save_checkpoint.emit() ) diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index 9ce11a71..97003a4d 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -11,6 +11,7 @@ import traceback from importlib import resources +import numpy as np from pandas import DataFrame from PyQt5.QtCore import pyqtSignal, Qt, QModelIndex from PyQt5.QtGui import QIcon @@ -29,6 +30,8 @@ get_runs, save_tmp_run, ) +from badger.errors import BadgerRoutineError +from badger.gui.components.data_panel import filter_metadata from badger.gui.components.data_table import ( add_row, data_table, @@ -277,6 +280,12 @@ def config_logic(self): self.routine_editor.env_box.var_table.refresh_current_values ) self.run_action_bar.sig_ctrl.connect(self.run_monitor.ctrl_routine) + self.run_action_bar.sig_run_with_data.connect( + lambda: self.start_run( + use_termination_condition=bool(self.run_monitor.termination_condition), + load_displayed_data=True, + ) + ) self.run_action_bar.sig_open_extensions_palette.connect( self.run_monitor.open_extensions_palette ) @@ -291,7 +300,7 @@ def _configure_default_run_action(self): self.run_action_bar.run_until_action ) # configure default to max_eval (tc_idx=0), 50 iterations - initial_tc = {"tc_idx": 0, "max_eval": 50, "max_time": 300, "ftol": 0} + initial_tc = {"tc_idx": 0, "max_eval": 100, "max_time": 300, "ftol": 0} self.run_monitor.save_termination_condition(initial_tc) self.run_action_bar.update_run_tooltip(initial_tc) @@ -429,7 +438,61 @@ def toggle_lock(self, lock, lock_tab=1): self.uncover_page() - def prepare_run(self): + def validate_loaded_data_keys(self, vocs, open_dialog: bool = True): + """ + This function is called when adding historical data to a new routine. + It makes sure that the keys of data to be loaded match the + selected variables and objectives in VOCS. If they do not, raises an error. + If the set of data keys matches provided VOCS variables + and objectives, opens a dialog to inform user that data has been added. + + Args: + vocs: VOCS + """ + # get routine selected from data_panel + routine = self.current_routine + + # Want to compare variables, objectives + loaded_data_vars_objs_names = ( + routine.vocs.variable_names + routine.vocs.objective_names + ) + + # Raise error if loaded data keys do not match selected vocs + if set(loaded_data_vars_objs_names) != set( + vocs.variable_names + vocs.objective_names + ): + self.run_action_bar.routine_finished() # Reset action bar + raise BadgerRoutineError( + "Keys in loaded data do not match selected VOCS:\n\n" + + f"Keys in data to load:\n {loaded_data_vars_objs_names}\n\n" + + f"Selected VOCS:\n {vocs.variable_names + vocs.objective_names}" + ) + + df = routine.sorted_data + data = df.to_dict(orient="list") + data = filter_metadata(data) + data_keys = data.keys() + + if open_dialog: + # Notify user that data has been added to the routine + dialog = QMessageBox( + text=str( + "Data loaded into routine for the following VOCS:\n\n" + + f"{list(data_keys)}\n\n" + + "Click OK to continue!" + ), + parent=self, + ) + dialog.setIcon(QMessageBox.Information) + dialog.setWindowTitle("Data added to routine") + dialog.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) + result = dialog.exec_() + + if result == QMessageBox.Cancel: + self.run_action_bar.routine_finished() # Reset action bar + raise BadgerRoutineError("Routine initialization cancelled by user.") + + def prepare_run(self, data=None, init_points_flag=True): """ Prepares the run by composing the routine, validating data if present, saving created routine to a yaml file, and passing the routine to @@ -441,10 +504,6 @@ def prepare_run(self): confirm that initial points are being sampled if there are new columns in the dataframe. If there are new columns and the flag is false, this function raises an error. - - Notes - _____ - Removed data loading implementation from mini GUI, """ logger.info("Preparing new run.") try: @@ -453,6 +512,35 @@ def prepare_run(self): self.sig_routine_invalid.emit() raise e + # Add data to routine before saving tmp file + if data is not None: + # Make sure selected generator is compatible with prior data + if routine.generator.name in ["neldermead"]: + self.run_action_bar.routine_finished() # Reset action bar + # TODO: update error message and/or support neldermead for resume function + raise BadgerRoutineError( + "Neldermead algorithm is not compatible with data loading. " + + "\nPlease uncheck 'Load displayed data into routine' " + + "or select a different algorithm." + ) + # Check that routine variables and objectives match loaded data + self.validate_loaded_data_keys(routine.vocs, open_dialog=False) + data["live"] = 0 # reset live data indicator for loaded data + for name in routine.vocs.output_names: + if name not in data.columns: + # Add null datapoints for new constraints or observables + data[name] = np.nan + + # Raise error if there are new columns (all NaN) and no initial points selected + if data.isna().all().any() and not init_points_flag: + self.run_action_bar.routine_finished() # Reset action bar + raise BadgerRoutineError( + "Must select at least one initial point in order to add" + + " new constraints to routine!" + ) + + routine.data = data + self.current_routine = routine # Save routine as a temp file @@ -463,7 +551,9 @@ def prepare_run(self): # Tell monitor to start the run self.run_monitor.init_plots(routine) - def start_run(self, use_termination_condition: bool = False): + def start_run( + self, use_termination_condition: bool = False, load_displayed_data: bool = False + ): """ Prepares and starts optimization run with provided options. - Termination Condition is provided when called via BadgerTerminationConditionDialog @@ -471,20 +561,45 @@ def start_run(self, use_termination_condition: bool = False): Args: use_termination_condition (bool): Is set as True if called from BadgerTerminationConditionDialog. + load_displayed_data (bool): If True loads data from the currently displayed routine. Notes: Removed data loading implementation and data_panel from mini GUI """ logger.info("Starting run.") - self.prepare_run() + # flags for loading data + run_data_flag = load_displayed_data + init_points_flag = True + if run_data_flag: + init_points_flag = False + + if run_data_flag: + data_to_load = self.load_data_from_run() + self.prepare_run( + data=data_to_load, + init_points_flag=init_points_flag, + ) # Pass data to prepare run, to be saved to tmp file and loaded into plots + self.run_monitor.init_plots(self.current_routine) + + # Add routine and generator data back to the routine + self.current_routine.data = data_to_load + if self.current_routine.generator.data is None: + self.current_routine.generator.data = data_to_load + else: + # run data flag is False + + self.prepare_run() self.run_monitor.start( use_termination_condition=use_termination_condition, - run_data_flag=False, - init_points_flag=True, + run_data_flag=run_data_flag, + init_points_flag=init_points_flag, ) + def load_data_from_run(self): + return self.current_routine.sorted_data + def start_run_until(self, dialog: bool = True): """ Starts run with termination condition. From 52145595ffb9f6e106e25030324bad810640f382 Mon Sep 17 00:00:00 2001 From: michaellans Date: Mon, 17 Aug 2026 15:07:43 -0700 Subject: [PATCH 11/36] add SIGTERM handler to raise BadgerRunTerminated --- src/badger/core_subprocess.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index ea2cf3a9..867e770f 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -12,6 +12,7 @@ from copy import deepcopy import logging +import signal import time import traceback from typing import Any @@ -47,6 +48,11 @@ logger = logging.getLogger(__name__) +def _terminate_on_sigterm(signum, frame): + # terminate cleanly if terminate signal comes before reaching stop_process check + raise BadgerRunTerminated + + def evaluate_measurement_with_retry( routine: Routine, point: Any, @@ -320,6 +326,9 @@ def run_routine_subprocess( logger.info("Optimization started") opt_logger.update(Events.OPTIMIZATION_START, solution_meta) + # So a terminate() from the GUI still runs the shutdown path below + signal.signal(signal.SIGTERM, _terminate_on_sigterm) + # evaluate initial points: # timeout logic will be handled in the specific environment try: From e6c0763124fe4f278871b176e8c417977630b564 Mon Sep 17 00:00:00 2001 From: michaellans Date: Mon, 17 Aug 2026 18:07:28 -0700 Subject: [PATCH 12/36] add routine data to run_table when loaded --- src/badger/gui/mini/pages/home_page.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index 97003a4d..fee6fe11 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -637,6 +637,14 @@ def new_run(self): # self.cb_history.insertItem(0, "Optimization in progress...") # self.cb_history.setCurrentIndex(0) + if self.current_routine.data is not None: + update_table( + self.run_table, + self.current_routine.sorted_data, + self.current_routine.vocs, + ) + return + header = get_header(self.current_routine) reset_table(self.run_table, header) From 02c155feb7e1b182485756919af182fc12d32130 Mon Sep 17 00:00:00 2001 From: michaellans Date: Thu, 10 Sep 2026 09:29:43 -0700 Subject: [PATCH 13/36] Skip error message on intentional BadgerRunTerminated during evaluate_measurement_with_retry --- src/badger/core_subprocess.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index 867e770f..bedf3304 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -63,6 +63,8 @@ def evaluate_measurement_with_retry( while True: try: return routine.evaluate_data(point) + except BadgerRunTerminated: + raise except Exception as e: error_title = f"{type(e).__name__}: {e}" error_traceback = traceback.format_exc() From bb004287d7e08d57e6833ba1c203668acd003842 Mon Sep 17 00:00:00 2001 From: michaellans Date: Thu, 10 Sep 2026 14:52:27 -0700 Subject: [PATCH 14/36] support resume run action in main gui --- src/badger/gui/pages/home_page.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/badger/gui/pages/home_page.py b/src/badger/gui/pages/home_page.py index 7dfe8f6f..64973ae0 100644 --- a/src/badger/gui/pages/home_page.py +++ b/src/badger/gui/pages/home_page.py @@ -257,6 +257,12 @@ def config_logic(self): self.run_action_bar.sig_start.connect(self.start_run) self.run_action_bar.sig_start_until.connect(self.start_run_until) + self.run_action_bar.sig_run_with_data.connect( + lambda: self.start_run( + use_termination_condition=bool(self.run_monitor.termination_condition), + load_displayed_data=True, + ) + ) self.run_action_bar.sig_stop.connect(self.run_monitor.stop) self.run_action_bar.sig_delete_run.connect(self.run_monitor.delete_run) self.run_action_bar.sig_logbook.connect(self.run_monitor.logbook) @@ -526,7 +532,11 @@ def prepare_run(self, data=None, init_points_flag=True): # Tell monitor to start the run self.run_monitor.init_plots(routine) - def start_run(self, use_termination_condition: bool = False): + def start_run( + self, + use_termination_condition: bool = False, + load_displayed_data: bool = False, + ): """ Prepares and starts optimization run with provided options. - Termination Condition is provided when called via BadgerTerminationConditionDialog @@ -538,7 +548,7 @@ def start_run(self, use_termination_condition: bool = False): """ logger.info("Starting run.") # Set data options based on checkbox states from data_panel - run_data_flag = self.data_panel.use_data + run_data_flag = load_displayed_data or self.data_panel.use_data init_points_flag = self.data_panel.init_points if run_data_flag: From 1234916f4aca217cf0c77644284928467e37b8ca Mon Sep 17 00:00:00 2001 From: michaellans Date: Thu, 10 Sep 2026 16:47:20 -0700 Subject: [PATCH 15/36] add separate args_queue for subprocess startup args instead of reusing data_and_error_queue --- src/badger/core_subprocess.py | 13 ++----------- src/badger/gui/components/create_process.py | 3 +++ src/badger/gui/components/routine_runner.py | 3 ++- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index bedf3304..f02447e2 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -48,11 +48,6 @@ logger = logging.getLogger(__name__) -def _terminate_on_sigterm(signum, frame): - # terminate cleanly if terminate signal comes before reaching stop_process check - raise BadgerRunTerminated - - def evaluate_measurement_with_retry( routine: Routine, point: Any, @@ -63,8 +58,6 @@ def evaluate_measurement_with_retry( while True: try: return routine.evaluate_data(point) - except BadgerRunTerminated: - raise except Exception as e: error_title = f"{type(e).__name__}: {e}" error_traceback = traceback.format_exc() @@ -194,6 +187,7 @@ def convert_to_solution(result: DataFrame, routine: Routine): def run_routine_subprocess( + args_queue: mp.Queue, queue: mp.Queue, evaluate_queue: mp.Pipe, stop_process: mp.Event, @@ -245,7 +239,7 @@ def run_routine_subprocess( args: dict[str, Any] = {} try: - args = queue.get(timeout=1) + args = args_queue.get(timeout=1) 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)}") @@ -328,9 +322,6 @@ def run_routine_subprocess( logger.info("Optimization started") opt_logger.update(Events.OPTIMIZATION_START, solution_meta) - # So a terminate() from the GUI still runs the shutdown path below - signal.signal(signal.SIGTERM, _terminate_on_sigterm) - # evaluate initial points: # timeout logic will be handled in the specific environment try: diff --git a/src/badger/gui/components/create_process.py b/src/badger/gui/components/create_process.py index b90a8b37..b32b3e35 100644 --- a/src/badger/gui/components/create_process.py +++ b/src/badger/gui/components/create_process.py @@ -33,6 +33,7 @@ def create_subprocess(self) -> None: """ self.stop_event = Event() self.pause_event = Event() + self.args_queue = Queue() self.data_queue = Queue() self.evaluate_queue = Pipe() self.wait_event = Event() @@ -47,6 +48,7 @@ def create_subprocess(self) -> None: new_process = Process( target=run_routine_subprocess, args=( + self.args_queue, self.data_queue, self.evaluate_queue, self.stop_event, @@ -61,6 +63,7 @@ def create_subprocess(self) -> None: self.subprocess_prepared.emit( { "process": new_process, + "args_queue": self.args_queue, "stop_event": self.stop_event, "pause_event": self.pause_event, "data_queue": self.data_queue, diff --git a/src/badger/gui/components/routine_runner.py b/src/badger/gui/components/routine_runner.py index 968cb5cf..c37a4f37 100644 --- a/src/badger/gui/components/routine_runner.py +++ b/src/badger/gui/components/routine_runner.py @@ -171,6 +171,7 @@ def run(self, run_data_flag: bool = False, init_points_flag: bool = False) -> No self.routine_process = process_with_args["process"] self.stop_event = process_with_args["stop_event"] self.pause_event = process_with_args["pause_event"] + self.args_queue = process_with_args["args_queue"] self.data_and_error_queue = process_with_args["data_queue"] self.evaluate_queue = process_with_args["evaluate_queue"] self.wait_event = process_with_args["wait_event"] @@ -191,7 +192,7 @@ def run(self, run_data_flag: bool = False, init_points_flag: bool = False) -> No "init_points": init_points_flag, } - self.data_and_error_queue.put(arg_dict) + self.args_queue.put(arg_dict) self.wait_event.set() self.pause_event.set() self.setup_timer() From 4f7455dfebe91daedc49f1c9b6d7c5e3df250a43 Mon Sep 17 00:00:00 2001 From: michaellans Date: Thu, 10 Sep 2026 17:09:55 -0700 Subject: [PATCH 16/36] remove unused import --- src/badger/core_subprocess.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index f02447e2..035b3c2f 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -12,7 +12,6 @@ from copy import deepcopy import logging -import signal import time import traceback from typing import Any From 845e6af4e161f8af3c049d6326b00b4a56bdf398 Mon Sep 17 00:00:00 2001 From: michaellans Date: Mon, 14 Sep 2026 17:52:15 -0700 Subject: [PATCH 17/36] minor termination_reached_dialog ui update --- .../gui/windows/termination_reached_dialog.py | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/badger/gui/windows/termination_reached_dialog.py b/src/badger/gui/windows/termination_reached_dialog.py index af5d76b6..56c3cc43 100644 --- a/src/badger/gui/windows/termination_reached_dialog.py +++ b/src/badger/gui/windows/termination_reached_dialog.py @@ -58,7 +58,7 @@ def __init__(self, tc_condition=None, text="", parent=None): tc_type = tc_condition["type"] if tc_type == "max_eval": - tc_type_text = "Max evaluation" + tc_type_text = "N iterations" state = tc_condition["state"] else: tc_type_text = "Timeout" @@ -70,21 +70,16 @@ def __init__(self, tc_condition=None, text="", parent=None): text_column = QVBoxLayout() text_column.setSpacing(3) - title_label = QLabel("Termination condition reached") - title_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - title_label.setStyleSheet("font-size: 14px; font-weight: 600;") - text_column.addWidget(title_label) - - body_label = QLabel("Badger optimization stopped.") - body_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - text_column.addWidget(body_label) - - summary_label = QLabel(f"{tc_type_text}: {state}/{tc_condition['config']}") + summary_label = QLabel(f"{tc_type_text}: {state} / {tc_condition['config']}") summary_label.setWordWrap(True) - summary_label.setAlignment(Qt.AlignLeft) - summary_label.setStyleSheet("color: #8A949E;") + summary_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + # summary_label.setStyleSheet("color: #8A949E;") text_column.addWidget(summary_label) + body_label = QLabel("Badger optimization paused") + body_label.setAlignment(Qt.AlignLeft) + text_column.addWidget(body_label) + content_row.addLayout(text_column) layout.addLayout(content_row) From a32654668b77e39fa6f4eff63a8b7082a7ef915d Mon Sep 17 00:00:00 2001 From: michaellans Date: Mon, 14 Sep 2026 18:05:19 -0700 Subject: [PATCH 18/36] add BadgerTerminationReachedDialog to supress_popups mocker for testing --- src/badger/tests/conftest.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/badger/tests/conftest.py b/src/badger/tests/conftest.py index 3d7961c7..265d489d 100644 --- a/src/badger/tests/conftest.py +++ b/src/badger/tests/conftest.py @@ -1,6 +1,7 @@ import os import shutil import pytest +from PyQt5.QtWidgets import QDialog @pytest.fixture(autouse=True) @@ -9,6 +10,10 @@ def suppress_popups(mocker): "badger.gui.windows.expandable_message_box.ExpandableMessageBox.exec_", return_value=None, ) + mocker.patch( + "badger.gui.windows.termination_reached_dialog.BadgerTerminationReachedDialog.exec_", + return_value=QDialog.Rejected, + ) @pytest.fixture(scope="module", autouse=True) From 76e72ebb50716230c95dba7983d22e6bdf4c36d1 Mon Sep 17 00:00:00 2001 From: michaellans Date: Mon, 14 Sep 2026 18:11:25 -0700 Subject: [PATCH 19/36] update tests for subprocess with args_queue --- src/badger/tests/test_core_subprocess.py | 10 ++++------ src/badger/tests/test_create_process.py | 2 ++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/badger/tests/test_core_subprocess.py b/src/badger/tests/test_core_subprocess.py index 1edecde4..972a3c0b 100644 --- a/src/badger/tests/test_core_subprocess.py +++ b/src/badger/tests/test_core_subprocess.py @@ -78,10 +78,9 @@ def test_run_routine_subprocess( } process_with_args = process_manager.remove_from_queue() pause_event = process_with_args["pause_event"] - data_queue = process_with_args["data_queue"] + args_queue = process_with_args["args_queue"] wait_event = process_with_args["wait_event"] routine_process = process_with_args["process"] - data_queue = process_with_args["data_queue"] evaluate_queue = process_with_args["evaluate_queue"] arg_dict = { @@ -95,7 +94,7 @@ def test_run_routine_subprocess( "start_time": time.time(), } - data_queue.put(arg_dict) + args_queue.put(arg_dict) wait_event.set() pause_event.set() @@ -165,10 +164,9 @@ def test_run_turbo(self, process_manager, init_multiprocessing) -> None: } process_with_args = process_manager.remove_from_queue() pause_event = process_with_args["pause_event"] - data_queue = process_with_args["data_queue"] + args_queue = process_with_args["args_queue"] wait_event = process_with_args["wait_event"] routine_process = process_with_args["process"] - data_queue = process_with_args["data_queue"] evaluate_queue = process_with_args["evaluate_queue"] arg_dict = { @@ -178,7 +176,7 @@ def test_run_turbo(self, process_manager, init_multiprocessing) -> None: "start_time": time.time(), } - data_queue.put(arg_dict) + args_queue.put(arg_dict) wait_event.set() pause_event.set() diff --git a/src/badger/tests/test_create_process.py b/src/badger/tests/test_create_process.py index 81e5b6b8..02fcfddf 100644 --- a/src/badger/tests/test_create_process.py +++ b/src/badger/tests/test_create_process.py @@ -38,6 +38,7 @@ def test_create_subprocess_emits_signals(qtbot, process_creator): emitted_args = blocker_subprocess_prepared.args[0] assert set(emitted_args.keys()) == { "process", + "args_queue", "stop_event", "pause_event", "data_queue", @@ -46,6 +47,7 @@ def test_create_subprocess_emits_signals(qtbot, process_creator): "dialog_action_queue", } + assert isinstance(emitted_args["args_queue"], mp.queues.Queue) assert isinstance(emitted_args["data_queue"], mp.queues.Queue) assert isinstance(emitted_args["dialog_action_queue"], mp.queues.Queue) assert isinstance(emitted_args["evaluate_queue"], tuple) From 8f7633bfb869b6bae069934aa75e9a8b6e4a0158 Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 15 Sep 2026 14:55:54 -0700 Subject: [PATCH 20/36] extract termination condition check logic into a separate method --- src/badger/core_subprocess.py | 147 ++++++++++++++++++++-------------- 1 file changed, 86 insertions(+), 61 deletions(-) diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index 035b3c2f..e9c30a1a 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -134,6 +134,78 @@ def pause_for_termination_dialog_action( ) +def check_termination_condition( + termination_condition: dict, + start_time: float, + routine: Routine, + queue: mp.Queue, + stop_process: mp.Event, + pause_process: mp.Event, + dialog_action_queue: mp.Queue, +) -> bool: + """ + Check whether termination conditon has been reached. + Pause for user action when a configured termination condition is reached. + """ + if not termination_condition or not start_time: + return False + + tc_config = termination_condition + idx = tc_config["tc_idx"] + if idx == 0: + max_eval = tc_config["max_eval"] + if routine.data is not None: + if "live" in routine.data.columns: + # Only count number of live data points + count = sum(1 for live_val in routine.data["live"] if live_val == 1) + else: + count = len(routine.data) + logger.debug(f"Checking max_eval termination: {count} >= {max_eval}") + else: + count = 0 + + if count >= max_eval: + logger.info( + "Max evaluations reached. Pausing optimization and waiting for user action." + ) + pause_process.clear() + pause_for_termination_dialog_action( + queue=queue, + stop_process=stop_process, + pause_process=pause_process, + dialog_action_queue=dialog_action_queue, + tc_condition={ + "type": "max_eval", + "config": max_eval, + "state": count, + }, + ) + return True + elif idx == 1: + max_time = tc_config["max_time"] + dt = time.time() - start_time + logger.debug(f"Checking max_time termination: {dt} >= {max_time}") + if dt >= max_time: + logger.info( + "Max time reached. Pausing optimization and waiting for user action." + ) + pause_process.clear() + pause_for_termination_dialog_action( + queue=queue, + stop_process=stop_process, + pause_process=pause_process, + dialog_action_queue=dialog_action_queue, + tc_condition={ + "type": "max_time", + "config": max_time, + "state": dt, + }, + ) + return True + + return False + + 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. @@ -348,67 +420,20 @@ def run_routine_subprocess( logger.info("Pause process not set. Waiting...") pause_process.wait() - if termination_condition and start_time: - tc_config = termination_condition - idx = tc_config["tc_idx"] - if idx == 0: - max_eval = tc_config["max_eval"] - if routine.data is not None: - if "live" in routine.data.columns: - # Only count number of live data points - count = sum( - 1 for live_val in routine.data["live"] if live_val == 1 - ) - else: - count = len(routine.data) - logger.debug( - f"Checking max_eval termination: {count} >= {max_eval}" - ) - else: - count = 0 - - if count >= max_eval: - logger.info( - "Max evaluations reached. Pausing optimization and waiting for user action." - ) - pause_process.clear() - pause_for_termination_dialog_action( - queue=queue, - stop_process=stop_process, - pause_process=pause_process, - dialog_action_queue=dialog_action_queue, - tc_condition={ - "type": "max_eval", - "config": max_eval, - "state": count, - }, - ) - # reset termination condition - termination_condition = None - continue - elif idx == 1: - max_time = tc_config["max_time"] - dt = time.time() - start_time - logger.debug(f"Checking max_time termination: {dt} >= {max_time}") - if dt >= max_time: - logger.info( - "Max time reached. Pausing optimization and waiting for user action." - ) - pause_process.clear() - pause_for_termination_dialog_action( - queue=queue, - stop_process=stop_process, - pause_process=pause_process, - dialog_action_queue=dialog_action_queue, - tc_condition={ - "type": "max_time", - "config": max_time, - "state": dt, - }, - ) - # reset termination condition - termination_condition = None - continue + if check_termination_condition( + termination_condition, + start_time, + routine, + queue, + stop_process, + pause_process, + dialog_action_queue, + ): + # termination_condition = extend_termination_condition( + # termination_condition, original_termination_condition + # ) + termination_condition = None + continue candidates = routine.generator.generate(1)[0] logger.debug(f"Generated candidates: {candidates}") From fa9c6d433d4fbcd8f986c3e1f2518c0aae04fb7b Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 15 Sep 2026 16:36:51 -0700 Subject: [PATCH 21/36] Add SmartRunController class to control play/pause/resume/restart behavior --- .../gui/mini/components/run_controller.py | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/badger/gui/mini/components/run_controller.py diff --git a/src/badger/gui/mini/components/run_controller.py b/src/badger/gui/mini/components/run_controller.py new file mode 100644 index 00000000..adc9bbd2 --- /dev/null +++ b/src/badger/gui/mini/components/run_controller.py @@ -0,0 +1,132 @@ +"""Coordinate pause, resume, continue, and restart behavior/signals for optimization runs""" + +import logging + +from PyQt5.QtCore import QObject, pyqtSignal + +logger = logging.getLogger(__name__) + + +class SmartRunController(QObject): + sig_pause_ctrl = pyqtSignal(bool) + sig_stop = pyqtSignal() + sig_start = pyqtSignal(bool) # bool: load_displayed_data + + def __init__(self) -> None: + super().__init__() + self.last_routine_dict = None + self._pending_start = False + self._load_data = False + self._new_routine_dict = None + + self.restart_override_flag: bool = False + + def smart_run( + self, + routine_params_dict: dict, + is_running: bool, + is_paused: bool, + data_compatible: bool, + ): + """ + Selects whether to pause, resume, continue, or restart a run. + + Parameters + ---------- + routine_params_dict : dict + Current routine parameters from the editor. + is_running : bool + Whether an optimization subprocess is active. + is_paused : bool + Whether the current optimization is paused. + data_compatible : bool + Whether the existing data can be reused with the new routine. + """ + self._new_routine_dict = routine_params_dict + + # Hitting 'stop' should pause the subprocess at the start of the optimization loop + if is_running and not is_paused: + # is_running means subprocess is active, not is_paused indicates optimization loop is active + logger.info("Pausing active routine") + self.sig_pause_ctrl.emit(True) + return + + if self.restart_override_flag: + # skip logic and restart fresh run without data + self.restart_override_flag = False # reset flag to false + else: + # Then when the button is pressed again to 'play': + # If nothing has changed on the GUI, it should just resume + if ( + self.last_routine_dict is not None + and routine_params_dict == self.last_routine_dict + ): + if is_running: + logger.info("Resuming (unpause) routine") + self.sig_pause_ctrl.emit(False) + return + else: + # routine has ended, need to start again + self.start_run(True) + return + + # If parameters like variable range or algorithm parameters have changed, it needs to stop, + # then start a new optimization process with the new generator parameters, and load in the previous data + # to 'continue' the optimization with new parameters + if self.last_routine_dict is not None and data_compatible: + if is_running: + self._pending_start = True + self._load_data = True + logger.info("Pending restart queued with displayed data") + self.sig_stop.emit() + return + # wait for routine_finished signal + else: + # there is a last_routine but no active subprocess. Start a new run + self.start_run(True) + return + + # If variables, objectives, have changed, it should stop, then start a + # new optimization with the default number of iterations + + # if running, stop and wait for routine_finished signal + if is_running: + self._pending_start = True + self._load_data = False + logger.info("Pending restart queued without displayed data") + self.sig_stop.emit() + # wait for routine_finished_signal + return + + # start fresh run + self.start_run(False) + + def set_restart_override_flag(self): + """ + This method sets a flag to skip logic and restart a new run without data on + the next play button press. It is called when selecting a past run from the + history tree, loading a template, reseting environment variables, or dialing + in a solution. + The flag will then be reset to false in self.smart_run(). + """ + self.restart_override_flag = True + + def notify_routine_finished(self): + """Start a pending run after the current routine finishes.""" + if self._pending_start is False: + return + + self.start_run(self._load_data) + + def start_run(self, load_displayed_data: bool): + """Emit the signal to start a run with/withoug displayed data. + + Parameters + ---------- + load_displayed_data : bool + Whether the displayed routine data should be loaded into the run. + """ + logger.info(f"Starting run (load_displayed_data={load_displayed_data})") + self._pending_start = False # reset to false + self.last_routine_dict = self._new_routine_dict + self.sig_start.emit(load_displayed_data) From 16ec1cf958d07854f55246f4c7f493659793c026 Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 15 Sep 2026 16:59:56 -0700 Subject: [PATCH 22/36] extract boolean compatibility check to new method loaded_data_keys_compatible --- src/badger/gui/mini/pages/home_page.py | 30 ++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index fee6fe11..2725b230 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -299,7 +299,7 @@ def _configure_default_run_action(self): self.run_action_bar.btn_stop.setDefaultAction( self.run_action_bar.run_until_action ) - # configure default to max_eval (tc_idx=0), 50 iterations + # configure default to max_eval (tc_idx=0), 100 iterations initial_tc = {"tc_idx": 0, "max_eval": 100, "max_time": 300, "ftol": 0} self.run_monitor.save_termination_condition(initial_tc) self.run_action_bar.update_run_tooltip(initial_tc) @@ -438,6 +438,20 @@ def toggle_lock(self, lock, lock_tab=1): self.uncover_page() + def loaded_data_keys_compatible(self, vocs) -> bool: + """True if the displayed routine has data whose variable/objective keys match vocs.""" + routine = self.current_routine + + if routine is None or routine.data is None or routine.data.empty: + return False + + loaded_data_vars_objs_names = ( + routine.vocs.variable_names + routine.vocs.objective_names + ) + return set(loaded_data_vars_objs_names) == set( + vocs.variable_names + vocs.objective_names + ) + def validate_loaded_data_keys(self, vocs, open_dialog: bool = True): """ This function is called when adding historical data to a new routine. @@ -452,19 +466,13 @@ def validate_loaded_data_keys(self, vocs, open_dialog: bool = True): # get routine selected from data_panel routine = self.current_routine - # Want to compare variables, objectives - loaded_data_vars_objs_names = ( - routine.vocs.variable_names + routine.vocs.objective_names - ) - - # Raise error if loaded data keys do not match selected vocs - if set(loaded_data_vars_objs_names) != set( - vocs.variable_names + vocs.objective_names - ): + if not self.loaded_data_keys_compatible(vocs): self.run_action_bar.routine_finished() # Reset action bar + if routine is None or routine.data is None or routine.data.empty: + raise BadgerRoutineError("The displayed routine has no data to load.") raise BadgerRoutineError( "Keys in loaded data do not match selected VOCS:\n\n" - + f"Keys in data to load:\n {loaded_data_vars_objs_names}\n\n" + + f"Keys in data to load:\n {routine.vocs.variable_names + routine.vocs.objective_names}\n\n" + f"Selected VOCS:\n {vocs.variable_names + vocs.objective_names}" ) From b243d95856133f1bfbd38d78def86195497c369b Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 15 Sep 2026 17:16:50 -0700 Subject: [PATCH 23/36] add smart_run_action and stop_run_action to BadgerActionBar run options menu --- src/badger/gui/components/action_bar.py | 83 +++++++++++++++++++++++-- src/badger/gui/mini/pages/home_page.py | 2 +- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/src/badger/gui/components/action_bar.py b/src/badger/gui/components/action_bar.py index e39d827f..7e7ec813 100644 --- a/src/badger/gui/components/action_bar.py +++ b/src/badger/gui/components/action_bar.py @@ -135,14 +135,16 @@ class BadgerActionBar(QWidget): sig_dial_in = pyqtSignal() sig_ctrl = pyqtSignal(bool) sig_run_with_data = pyqtSignal() + sig_smart_run_ctrl = pyqtSignal() sig_open_extensions_palette = pyqtSignal() sig_save_checkpoint = pyqtSignal() sig_edit_checkpoint = pyqtSignal() sig_load_checkpoint = pyqtSignal() - def __init__(self, parent=None): + def __init__(self, parent=None, minimode: bool = False): super().__init__(parent) + self.mini_mode = minimode self.docs_name = "gui-usage" self.init_ui() self.config_logic() @@ -231,7 +233,7 @@ def load_internal_icon(name: str) -> QIcon: # Create a menu and add options self.run_menu = menu = QMenu(self) menu.setFixedWidth(128) - self.run_action = run_action = QAction("Run", self) + self.run_action = run_action = QAction("Run (restart)", self) run_action.setIcon(self.icon_play) self.run_until_action = run_until_action = QAction("Run until", self) run_until_action.setIcon(self.icon_play) @@ -239,13 +241,23 @@ def load_internal_icon(name: str) -> QIcon: run_until_menu_action.setIcon(self.icon_play) self.run_with_data_action = run_with_data_action = QAction("Resume", self) run_with_data_action.setIcon(self.icon_play) + self.smart_run_action = smart_run_action = QAction("Smart run", self) + smart_run_action.setIcon(self.icon_play) + self.stop_run_action = QAction("Stop", self) + self.stop_run_action.setIcon(self.icon_stop) menu.addAction(run_action) menu.addAction(run_until_menu_action) menu.addAction(run_with_data_action) - # Note: run_until_menu_action is triggered by selecting "run until" from the menu + if self.mini_mode: + menu.addAction(smart_run_action) + # Note: + # run_until_menu_action is triggered by selecting "run until" from the menu # It emits sig_start_until(True) to launch the BadgerTerminationConditionDialog # and sets the default run action to run_until_action. Pressing the play/stop button # will then emit sig_start_until(False) and skip the dialog popup. + # + # smart_run_action will pause an active run, and triggers logic to resume, + # stop and run with data, or restart (see gui/mini/components/run_controller.py) # Set the menu as the run button's dropdown menu self.btn_stop.setMenu(menu) @@ -295,6 +307,8 @@ def config_logic(self): self.run_until_menu_action.triggered.connect( self._on_run_until_menu_action_triggered ) + self.smart_run_action.triggered.connect(self._on_smart_run_action_triggered) + self.stop_run_action.triggered.connect(lambda: self.sig_stop.emit()) self.run_with_data_action.triggered.connect( lambda: self.sig_run_with_data.emit() ) @@ -341,12 +355,14 @@ def routine_finished(self): # Note the order of the following two lines cannot be changed! self.btn_stop.setPopupMode(QToolButton.MenuButtonPopup) self.btn_stop.setStyleSheet(stylesheet_run) - self.run_action.setText("Run") + self.run_action.setText("Run (restart)") self.run_action.setIcon(self.icon_play) self.run_until_action.setText("Run until") self.run_until_action.setIcon(self.icon_play) self.run_until_menu_action.setText("Run until") self.run_until_menu_action.setIcon(self.icon_play) + self.smart_run_action.setText("Smart run") + self.smart_run_action.setIcon(self.icon_play) # self.btn_stop.setToolTip('') self.btn_stop.setDisabled(False) @@ -368,7 +384,7 @@ def toggle_other(self, locked): def run_start(self): self.btn_stop.setStyleSheet(stylesheet_stop) - self.btn_stop.setPopupMode(QToolButton.DelayedPopup) + # self.btn_stop.setPopupMode(QToolButton.DelayedPopup) self.btn_stop.setDisabled(False) self.run_action.setText("Stop") self.run_action.setIcon(self.icon_stop) @@ -376,6 +392,8 @@ def run_start(self): self.run_until_action.setIcon(self.icon_stop) self.run_until_menu_action.setText("Stop") self.run_until_menu_action.setIcon(self.icon_stop) + self.smart_run_action.setText("Pause") + self.smart_run_action.setIcon(self.icon_pause) self.btn_checkpoint.setDisabled(False) self.btn_ctrl.setDisabled(False) self.btn_set.setDisabled(True) @@ -384,7 +402,7 @@ def set_run_action(self): if self.btn_stop.defaultAction() is not self.run_action: self.btn_stop.setDefaultAction(self.run_action) - if self.run_action.text() == "Run": + if self.run_action.text() == "Run (restart)": self.btn_stop.setDisabled(True) self.sig_start.emit() else: @@ -401,6 +419,12 @@ def set_run_until_action(self, from_menu=False): self.btn_stop.setDisabled(True) self.sig_stop.emit() + def set_smart_run_action(self): + if self.btn_stop.defaultAction() is not self.smart_run_action: + self.btn_stop.setDefaultAction(self.smart_run_action) + + self.sig_smart_run_ctrl.emit() + def _on_run_action_triggered(self): self.set_run_action() @@ -410,6 +434,9 @@ def _on_run_until_action_triggered(self): def _on_run_until_menu_action_triggered(self): self.set_run_until_action(from_menu=True) + def _on_smart_run_action_triggered(self): + self.set_smart_run_action() + def delete_run(self): self.sig_delete_run.emit() @@ -429,6 +456,50 @@ def jump_to_optimal(self): def dial_in(self): self.sig_dial_in.emit() + def handle_pause_action(self, status: bool): + """ + Enable/disable buttons for pause (true)/resume (false) optimization + """ + if status: + self.btn_stop.setStyleSheet(stylesheet_run) + self.smart_run_action.setIcon(self.icon_play) + self.btn_stop.setDisabled(False) + self.btn_reset.setDisabled(False) + self.btn_set.setDisabled(False) + self.btn_del.setDisabled(False) + + else: + self.btn_stop.setStyleSheet(stylesheet_stop) + self.smart_run_action.setIcon(self.icon_pause) + self.btn_stop.setDisabled(False) + self.btn_checkpoint.setDisabled(False) + self.btn_ctrl.setDisabled(False) + self.btn_set.setDisabled(True) + self.btn_reset.setDisabled(True) + + self.update_stop_menu(status) + + def update_stop_menu(self, status: bool): + """Update run menu options when routine is paused (true)/running (false)""" + if status: + self.run_menu.clear() + self.run_action.setText("Run (restart)") + self.run_action.setIcon(self.icon_play) + self.run_until_action.setText("Run until") + self.run_until_action.setIcon(self.icon_play) + self.run_until_menu_action.setText("Run until") + self.run_until_menu_action.setIcon(self.icon_play) + self.smart_run_action.setText("Smart run") + self.smart_run_action.setIcon(self.icon_play) + self.run_menu.addAction(self.run_action) + self.run_menu.addAction(self.run_until_menu_action) + self.run_menu.addAction(self.run_with_data_action) + self.run_menu.addAction(self.smart_run_action) + else: + self.run_menu.clear() + self.run_menu.addAction(self.stop_run_action) + self.run_menu.addAction(self.smart_run_action) + def ctrl_routine(self): if self.btn_ctrl._status == "pause": self.sig_ctrl.emit(True) diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index 2725b230..5a2da312 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -170,7 +170,7 @@ def init_ui(self): self.history_browser = self.routine_editor.history_browser # Add action bar - self.run_action_bar = run_action_bar = BadgerActionBar() + self.run_action_bar = run_action_bar = BadgerActionBar(minimode=True) run_action_bar.docs_name = "minimode" # Run panel (routine editor + run monitor + data table + action bar) From 0a1b1850925ed908856703460592d0e7f9de22c8 Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 15 Sep 2026 17:21:55 -0700 Subject: [PATCH 24/36] add run_controller to mini/home_page.py for smart_run --- src/badger/gui/mini/pages/home_page.py | 54 +++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index 5a2da312..d9287d5b 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -39,7 +39,7 @@ update_table, ) from badger.gui.mini.pages.routine_page import BadgerRoutinePage - +from badger.gui.mini.components.run_controller import SmartRunController from badger.gui.components.navigators import TemplateNavigator from badger.gui.components.run_monitor import BadgerOptMonitor from badger.gui.components.status_bar import BadgerStatusBar @@ -86,6 +86,8 @@ def __init__(self, process_manager=None): self.current_routine = None # current routine self.go_run_failed = False # flag to indicate go_run failed + self.run_controller = SmartRunController() # handle stop/start/resume + self.init_ui() self.config_logic() @@ -227,10 +229,16 @@ def config_logic(self): self.history_browser.history_tree_widget.itemSelectionChanged.connect( self.go_run ) + self.history_browser.history_tree_widget.itemSelectionChanged.connect( + self.run_controller.set_restart_override_flag + ) self.template_browser.template_tree_view.clicked.connect(self.go_template) self.routine_editor.sig_load_template.connect(self.update_status) + self.routine_editor.sig_load_template.connect( + self.run_controller.set_restart_override_flag + ) self.routine_editor.sig_save_template.connect(self.update_status) self.routine_editor.sig_go_run.connect(self.go_run) @@ -247,6 +255,9 @@ def config_logic(self): self.run_monitor.sig_routine_finished.connect( self.run_action_bar.routine_finished ) + self.run_monitor.sig_routine_finished.connect( + self.run_controller.notify_routine_finished + ) self.run_monitor.sig_lock_action.connect(self.run_action_bar.lock) self.run_monitor.sig_toggle_reset.connect(self.run_action_bar.toggle_reset) self.run_monitor.sig_toggle_run.connect(self.run_action_bar.toggle_run) @@ -263,6 +274,9 @@ def config_logic(self): self.run_action_bar.sig_reset_env.connect( self.routine_editor.env_box.var_table.refresh_current_values ) + self.run_action_bar.sig_reset_env.connect( + self.run_controller.set_restart_override_flag + ) self.run_action_bar.sig_save_checkpoint.connect( self.run_monitor.save_checkpoint ) @@ -279,7 +293,11 @@ def config_logic(self): self.run_action_bar.sig_dial_in.connect( self.routine_editor.env_box.var_table.refresh_current_values ) + self.run_action_bar.sig_dial_in.connect( + self.run_controller.set_restart_override_flag + ) self.run_action_bar.sig_ctrl.connect(self.run_monitor.ctrl_routine) + self.run_action_bar.sig_smart_run_ctrl.connect(self.smart_run_with_data) self.run_action_bar.sig_run_with_data.connect( lambda: self.start_run( use_termination_condition=bool(self.run_monitor.termination_condition), @@ -292,12 +310,21 @@ def config_logic(self): self.sig_routine_invalid.connect(self.run_action_bar.routine_invalid) + self.run_controller.sig_pause_ctrl.connect(self.handle_pause) + self.run_controller.sig_stop.connect(self.run_monitor.stop) + self.run_controller.sig_start.connect( + lambda load_displayed_data: self.start_run( + use_termination_condition=bool(self.run_monitor.termination_condition), + load_displayed_data=load_displayed_data, # arg from signal + ) + ) + self._configure_default_run_action() def _configure_default_run_action(self): """Set the default run action as run_until_action""" self.run_action_bar.btn_stop.setDefaultAction( - self.run_action_bar.run_until_action + self.run_action_bar.smart_run_action ) # configure default to max_eval (tc_idx=0), 100 iterations initial_tc = {"tc_idx": 0, "max_eval": 100, "max_time": 300, "ftol": 0} @@ -500,6 +527,24 @@ def validate_loaded_data_keys(self, vocs, open_dialog: bool = True): self.run_action_bar.routine_finished() # Reset action bar raise BadgerRoutineError("Routine initialization cancelled by user.") + def smart_run_with_data(self): + # get current routine_page parameters + routine_editor_snapshot = self.routine_editor.get_routine_snapshot() + vocs = self.routine_editor.env_box.compose_vocs()[0] + data_compatible = self.loaded_data_keys_compatible(vocs) + + self.run_controller.smart_run( + routine_params_dict=routine_editor_snapshot, + is_running=self.run_monitor.running, # is a subprocess active + is_paused=self.run_monitor.paused, # is optimization loop paused + data_compatible=data_compatible, + ) + + def handle_pause(self, pause: bool): + self.run_monitor.ctrl_routine(pause) + self.run_action_bar.handle_pause_action(pause) + self.toggle_lock(not pause) + def prepare_run(self, data=None, init_points_flag=True): """ Prepares the run by composing the routine, validating data if present, @@ -576,6 +621,11 @@ def start_run( """ logger.info("Starting run.") + if self.run_monitor.running: + # make sure stopped before starting new one + # this could happen if switching run modes from smart_run to normal while routine is paused + self.run_monitor.stop() + # flags for loading data run_data_flag = load_displayed_data init_points_flag = True From 623c7525eb2d3e1342324b238e91d51a00452417 Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 15 Sep 2026 17:26:06 -0700 Subject: [PATCH 25/36] add self.paused status to run_monitor --- src/badger/gui/components/run_monitor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/badger/gui/components/run_monitor.py b/src/badger/gui/components/run_monitor.py index bfba360f..4aa696ca 100644 --- a/src/badger/gui/components/run_monitor.py +++ b/src/badger/gui/components/run_monitor.py @@ -94,7 +94,8 @@ def __init__(self, process_manager=None): # Run optimization self.routine_runner = None - self.running = False + self.running = False # is subprocess running + self.paused = False # is optimization paused # Termination condition for the run self.termination_condition = None @@ -480,6 +481,7 @@ def start( if use_termination_condition: self.routine_runner.set_termination_condition(self.termination_condition) self.running = True # if a routine runner is working + self.paused = False self.routine_runner.run( run_data_flag=run_data_flag, init_points_flag=init_points_flag ) @@ -645,6 +647,7 @@ def env_ready(self, init_vars) -> None: def routine_finished(self) -> None: self.running = False + self.paused = False self.sig_routine_finished.emit() self.sig_lock.emit(False) @@ -725,6 +728,7 @@ def logbook(self): # self, 'Success!', f'') def ctrl_routine(self, status): + self.paused = status self.sig_pause.emit(status) def ins_obj_dragged(self, ins_obj): From f3166dae6252649576a5a9bcd60136d9e7a0f2d0 Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 15 Sep 2026 17:28:30 -0700 Subject: [PATCH 26/36] Add get_routine_snapshot and switch template_cb trigger from textChanged to activated --- src/badger/gui/mini/pages/routine_page.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/badger/gui/mini/pages/routine_page.py b/src/badger/gui/mini/pages/routine_page.py index 4bed3b91..783eb37c 100644 --- a/src/badger/gui/mini/pages/routine_page.py +++ b/src/badger/gui/mini/pages/routine_page.py @@ -245,7 +245,7 @@ def config_logic(self): logger.info("Configuring logic for BadgerRoutinePage.") # self.btn_descr_update.clicked.connect(self.update_description) self.env_box.load_template_button.clicked.connect(self.load_template_yaml) - self.env_box.template_cb.currentTextChanged.connect( + self.env_box.template_cb.activated.connect( lambda: ( self.load_template_yaml( template_path=self.env_box.template_cb.currentText() + ".yaml" @@ -297,6 +297,10 @@ def set_saved_values_from_init_vars( } self.env_box.var_table.set_saved_values(values_by_name) + def get_routine_snapshot(self): + routine_dict = self.generate_template_dict_from_gui() + return routine_dict + def load_template_yaml( self, checked_state=None, template_path: str | None = None ) -> None: From af34d4866c30bea34d27bcaebf5a517cf60eeb22 Mon Sep 17 00:00:00 2001 From: michaellans Date: Wed, 16 Sep 2026 10:05:40 -0700 Subject: [PATCH 27/36] update closeEvent handling to check for paused state and stop routine/close subprocess --- src/badger/gui/mini/windows/main_window.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/badger/gui/mini/windows/main_window.py b/src/badger/gui/mini/windows/main_window.py index 1b3c4fec..1a559b79 100644 --- a/src/badger/gui/mini/windows/main_window.py +++ b/src/badger/gui/mini/windows/main_window.py @@ -106,6 +106,11 @@ def closeEvent(self, event) -> None: self.process_manager.close_proccesses() monitor.destroy_unused_env() return + elif monitor.paused: + monitor.routine_runner.stop_routine() + self.process_manager.close_proccesses() + monitor.destroy_unused_env() + return reply = QMessageBox.question( self, From b3ebbc78caccdd5828644748f6c3a10991ec33ef Mon Sep 17 00:00:00 2001 From: michaellans Date: Wed, 16 Sep 2026 10:16:18 -0700 Subject: [PATCH 28/36] patch to allow adding data to sequential generators --- src/badger/core_subprocess.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index e9c30a1a..ff667904 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -42,6 +42,7 @@ from badger.log import configure_process_logging from xopt.errors import FeasibilityError, XoptError from xopt.vocs import select_best +from xopt.generators.sequential import SequentialGenerator logger = logging.getLogger(__name__) @@ -332,6 +333,9 @@ def run_routine_subprocess( if routine.data is not None: logger.info("Resetting routine data") routine.data = routine.data.iloc[0:0] # reset the data + else: + if isinstance(routine.generator, SequentialGenerator): + routine.generator.add_data(routine.data.copy()) except Exception as e: error_title = f"{type(e).__name__}: {e}" From 25ba2de9fd8d9949cd7b4e904e3651d7024f2c28 Mon Sep 17 00:00:00 2001 From: michaellans Date: Wed, 16 Sep 2026 10:29:19 -0700 Subject: [PATCH 29/36] remove error message when loading data with neldermead --- src/badger/gui/mini/pages/home_page.py | 9 --------- src/badger/gui/pages/home_page.py | 8 -------- 2 files changed, 17 deletions(-) diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index d9287d5b..4bb05f44 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -567,15 +567,6 @@ def prepare_run(self, data=None, init_points_flag=True): # Add data to routine before saving tmp file if data is not None: - # Make sure selected generator is compatible with prior data - if routine.generator.name in ["neldermead"]: - self.run_action_bar.routine_finished() # Reset action bar - # TODO: update error message and/or support neldermead for resume function - raise BadgerRoutineError( - "Neldermead algorithm is not compatible with data loading. " - + "\nPlease uncheck 'Load displayed data into routine' " - + "or select a different algorithm." - ) # Check that routine variables and objectives match loaded data self.validate_loaded_data_keys(routine.vocs, open_dialog=False) data["live"] = 0 # reset live data indicator for loaded data diff --git a/src/badger/gui/pages/home_page.py b/src/badger/gui/pages/home_page.py index 64973ae0..c4e054de 100644 --- a/src/badger/gui/pages/home_page.py +++ b/src/badger/gui/pages/home_page.py @@ -493,14 +493,6 @@ def prepare_run(self, data=None, init_points_flag=True): # Add data to routine before saving tmp file if data is not None: - # Make sure selected generator is compatible with prior data - if routine.generator.name in ["neldermead"]: - self.run_action_bar.routine_finished() # Reset action bar - raise BadgerRoutineError( - "Neldermead algorithm is not compatible with data loading. " - + "\nPlease uncheck 'Load displayed data into routine' " - + "or select a different algorithm." - ) # Check that routine variables and objectives match loaded data self.validate_loaded_data_keys(routine.vocs) self.data_panel.set_routine(routine) From 29d8d7e726625ea121b0be18e51c1b65a59c58db Mon Sep 17 00:00:00 2001 From: michaellans Date: Wed, 16 Sep 2026 10:39:37 -0700 Subject: [PATCH 30/36] update status with termination condition while running --- src/badger/core_subprocess.py | 6 ++++++ src/badger/gui/components/routine_runner.py | 7 +++++++ src/badger/gui/components/run_monitor.py | 21 +++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index ff667904..a9ad7abc 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -437,6 +437,12 @@ def run_routine_subprocess( # termination_condition, original_termination_condition # ) termination_condition = None + queue.put( + { + "type": "termination_extended", + "termination_condition": termination_condition, + } + ) continue candidates = routine.generator.generate(1)[0] diff --git a/src/badger/gui/components/routine_runner.py b/src/badger/gui/components/routine_runner.py index c37a4f37..71db85be 100644 --- a/src/badger/gui/components/routine_runner.py +++ b/src/badger/gui/components/routine_runner.py @@ -93,6 +93,7 @@ def __init__( self.termination_condition = ( None # additional option to control the optimization flow ) + self.active_tc = None self.start_time = None # track the time cost of the run self.last_dump_time = None # track the time the run data got dumped self.data_and_error_queue = None @@ -114,6 +115,7 @@ def set_termination_condition(self, termination_condition: dict) -> None: termination_condition : dict """ self.termination_condition = termination_condition + self.active_tc = termination_condition def run(self, run_data_flag: bool = False, init_points_flag: bool = False) -> None: """ @@ -281,6 +283,11 @@ def check_queue(self) -> None: "action": action, } ) + elif ( + isinstance(msg, dict) and msg.get("type") == "termination_extended" + ): + # check whether termination condition has been updated in subprocess + self.active_tc = msg["termination_condition"] else: error_title, error_traceback = msg BadgerError(error_title, error_traceback) diff --git a/src/badger/gui/components/run_monitor.py b/src/badger/gui/components/run_monitor.py index 4aa696ca..ef226032 100644 --- a/src/badger/gui/components/run_monitor.py +++ b/src/badger/gui/components/run_monitor.py @@ -531,10 +531,31 @@ def update(self, results: pd.DataFrame) -> None: self.extensions_palette.update_palette() self.sig_progress.emit(self.routine.data.tail(1)) + self.update_status_with_tc() # Check critical condition self.check_critical() + def update_status_with_tc(self): + termination_condition = self.routine_runner.active_tc + if termination_condition: + idx = self.termination_condition["tc_idx"] + if idx == 0: + max_eval = termination_condition["max_eval"] + data = self.routine.data + if data is not None: + if "live" in data.columns: + # Only count number of live data points + count = sum(1 for live_val in data["live"] if live_val == 1) + else: + count = len(data) + if not self.paused: + self.sig_status.emit( + f"Running routine {self.routine.name}... [{count}/{max_eval}]" + ) + else: + self.sig_status.emit(f"Running routine {self.routine.name}...") + def update_curves(self, results=None): use_time_axis = self.plot_x_axis == 1 norm_inputs = self.x_plot_y_axis == 1 From fb219cb871cf44f040fd176297fb831bbe94117c Mon Sep 17 00:00:00 2001 From: michaellans Date: Wed, 16 Sep 2026 10:47:58 -0700 Subject: [PATCH 31/36] update run_controller comments for clarity --- .../gui/mini/components/run_controller.py | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/src/badger/gui/mini/components/run_controller.py b/src/badger/gui/mini/components/run_controller.py index adc9bbd2..5eea978b 100644 --- a/src/badger/gui/mini/components/run_controller.py +++ b/src/badger/gui/mini/components/run_controller.py @@ -1,4 +1,4 @@ -"""Coordinate pause, resume, continue, and restart behavior/signals for optimization runs""" +"""Coordinate pause, resume, continuation, and restart behavior for optimization runs.""" import logging @@ -10,7 +10,7 @@ class SmartRunController(QObject): sig_pause_ctrl = pyqtSignal(bool) sig_stop = pyqtSignal() - sig_start = pyqtSignal(bool) # bool: load_displayed_data + sig_start = pyqtSignal(bool) # bool indicates whether to load displayed data. def __init__(self) -> None: super().__init__() @@ -29,7 +29,7 @@ def smart_run( data_compatible: bool, ): """ - Selects whether to pause, resume, continue, or restart a run. + Determine whether to pause, resume, continue, or restart a run. Parameters ---------- @@ -44,19 +44,18 @@ def smart_run( """ self._new_routine_dict = routine_params_dict - # Hitting 'stop' should pause the subprocess at the start of the optimization loop + # Pause the subprocess at the start of the optimization loop. if is_running and not is_paused: - # is_running means subprocess is active, not is_paused indicates optimization loop is active + # An active subprocess with an unpaused loop is currently optimizing. logger.info("Pausing active routine") self.sig_pause_ctrl.emit(True) return if self.restart_override_flag: - # skip logic and restart fresh run without data - self.restart_override_flag = False # reset flag to false + # Skip the restart logic and restart without existing data. + self.restart_override_flag = False # Reset the override flag. else: - # Then when the button is pressed again to 'play': - # If nothing has changed on the GUI, it should just resume + # When the button is pressed again, resume if the routine is unchanged. if ( self.last_routine_dict is not None and routine_params_dict == self.last_routine_dict @@ -66,13 +65,12 @@ def smart_run( self.sig_pause_ctrl.emit(False) return else: - # routine has ended, need to start again + # The routine has ended, so start it again. self.start_run(True) return - # If parameters like variable range or algorithm parameters have changed, it needs to stop, - # then start a new optimization process with the new generator parameters, and load in the previous data - # to 'continue' the optimization with new parameters + # If compatible parameters changed, restart with the new parameters and + # load the previous data to continue the optimization. if self.last_routine_dict is not None and data_compatible: if is_running: self._pending_start = True @@ -80,22 +78,21 @@ def smart_run( logger.info("Pending restart queued with displayed data") self.sig_stop.emit() return - # wait for routine_finished signal else: - # there is a last_routine but no active subprocess. Start a new run + # A previous routine exists, but no subprocess is active; start a new run. self.start_run(True) return - # If variables, objectives, have changed, it should stop, then start a - # new optimization with the default number of iterations + # If variables or objectives changed, start a new optimization with the + # default number of iterations. - # if running, stop and wait for routine_finished signal + # If running, stop and wait for the routine_finished signal. if is_running: self._pending_start = True self._load_data = False logger.info("Pending restart queued without displayed data") self.sig_stop.emit() - # wait for routine_finished_signal + # Wait for the routine_finished signal. return # start fresh run @@ -105,9 +102,9 @@ def set_restart_override_flag(self): """ This method sets a flag to skip logic and restart a new run without data on the next play button press. It is called when selecting a past run from the - history tree, loading a template, reseting environment variables, or dialing + history tree, loading a template, resetting environment variables, or dialing in a solution. - The flag will then be reset to false in self.smart_run(). + The flag is reset in `smart_run`. """ self.restart_override_flag = True @@ -119,7 +116,7 @@ def notify_routine_finished(self): self.start_run(self._load_data) def start_run(self, load_displayed_data: bool): - """Emit the signal to start a run with/withoug displayed data. + """Emit the signal to start a run with or without displayed data. Parameters ---------- @@ -127,6 +124,6 @@ def start_run(self, load_displayed_data: bool): Whether the displayed routine data should be loaded into the run. """ logger.info(f"Starting run (load_displayed_data={load_displayed_data})") - self._pending_start = False # reset to false + self._pending_start = False # Reset the _pending_start flag. self.last_routine_dict = self._new_routine_dict self.sig_start.emit(load_displayed_data) From d9712217a09dcf1ee0a265e1e0bdb0f4c7286fb4 Mon Sep 17 00:00:00 2001 From: michaellans Date: Wed, 16 Sep 2026 11:15:33 -0700 Subject: [PATCH 32/36] remove pause button --- src/badger/gui/components/action_bar.py | 26 ------------------------- src/badger/gui/mini/pages/home_page.py | 1 - src/badger/gui/pages/home_page.py | 1 - 3 files changed, 28 deletions(-) diff --git a/src/badger/gui/components/action_bar.py b/src/badger/gui/components/action_bar.py index 7e7ec813..4d039e23 100644 --- a/src/badger/gui/components/action_bar.py +++ b/src/badger/gui/components/action_bar.py @@ -187,8 +187,6 @@ def load_internal_icon(name: str) -> QIcon: ) self.btn_opt = create_button("star.png", "Jump to optimum") self.btn_set = create_button("set.png", "Dial in solution") - self.btn_ctrl = create_button("pause.png", "Pause") - self.btn_ctrl._status = "pause" self.btn_del.setDisabled(True) self.btn_log.setDisabled(True) @@ -196,7 +194,6 @@ def load_internal_icon(name: str) -> QIcon: self.btn_checkpoint.setDisabled(True) self.btn_opt.setDisabled(True) self.btn_set.setDisabled(True) - self.btn_ctrl.setDisabled(True) # self.btn_stop = btn_stop = QPushButton('Run') self.btn_stop = SplitTooltipToolButton(menu_tooltip="Run Options Menu") @@ -279,7 +276,6 @@ def load_internal_icon(name: str) -> QIcon: hbox_bg.addWidget(self.btn_help) hbox_bg.addStretch(1) hbox_bg.addWidget(self.btn_reset) - hbox_bg.addWidget(self.btn_ctrl) hbox_bg.addWidget(self.btn_stop) hbox_bg.addWidget(self.btn_checkpoint) hbox_bg.addWidget(self.btn_opt) @@ -301,7 +297,6 @@ def config_logic(self): self.btn_reset.clicked.connect(self.reset_env) self.btn_opt.clicked.connect(self.jump_to_optimal) self.btn_set.clicked.connect(self.dial_in) - self.btn_ctrl.clicked.connect(self.ctrl_routine) self.run_action.triggered.connect(self._on_run_action_triggered) self.run_until_action.triggered.connect(self._on_run_until_action_triggered) self.run_until_menu_action.triggered.connect( @@ -328,7 +323,6 @@ def lock(self): self.btn_log.setDisabled(True) self.btn_reset.setDisabled(True) self.btn_checkpoint.setDisabled(True) - self.btn_ctrl.setDisabled(True) self.btn_stop.setDisabled(True) self.btn_opt.setDisabled(True) self.btn_set.setDisabled(True) @@ -338,7 +332,6 @@ def unlock(self): self.btn_log.setDisabled(False) self.btn_reset.setDisabled(False) self.btn_checkpoint.setDisabled(False) - self.btn_ctrl.setDisabled(False) self.btn_stop.setDisabled(False) self.btn_opt.setDisabled(False) self.btn_set.setDisabled(False) @@ -347,11 +340,6 @@ def routine_invalid(self): self.btn_stop.setDisabled(False) def routine_finished(self): - self.btn_ctrl.setIcon(self.icon_pause) - self.btn_ctrl.setToolTip("Pause") - self.btn_ctrl._status = "pause" - self.btn_ctrl.setDisabled(True) - # Note the order of the following two lines cannot be changed! self.btn_stop.setPopupMode(QToolButton.MenuButtonPopup) self.btn_stop.setStyleSheet(stylesheet_run) @@ -395,7 +383,6 @@ def run_start(self): self.smart_run_action.setText("Pause") self.smart_run_action.setIcon(self.icon_pause) self.btn_checkpoint.setDisabled(False) - self.btn_ctrl.setDisabled(False) self.btn_set.setDisabled(True) def set_run_action(self): @@ -473,7 +460,6 @@ def handle_pause_action(self, status: bool): self.smart_run_action.setIcon(self.icon_pause) self.btn_stop.setDisabled(False) self.btn_checkpoint.setDisabled(False) - self.btn_ctrl.setDisabled(False) self.btn_set.setDisabled(True) self.btn_reset.setDisabled(True) @@ -500,18 +486,6 @@ def update_stop_menu(self, status: bool): self.run_menu.addAction(self.stop_run_action) self.run_menu.addAction(self.smart_run_action) - def ctrl_routine(self): - if self.btn_ctrl._status == "pause": - self.sig_ctrl.emit(True) - self.btn_ctrl.setIcon(self.icon_play) - self.btn_ctrl.setToolTip("Resume") - self.btn_ctrl._status = "play" - else: - self.sig_ctrl.emit(False) - self.btn_ctrl.setIcon(self.icon_pause) - self.btn_ctrl.setToolTip("Pause") - self.btn_ctrl._status = "pause" - def open_extensions_palette(self): self.sig_open_extensions_palette.emit() diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index 4bb05f44..acfa989c 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -296,7 +296,6 @@ def config_logic(self): self.run_action_bar.sig_dial_in.connect( self.run_controller.set_restart_override_flag ) - self.run_action_bar.sig_ctrl.connect(self.run_monitor.ctrl_routine) self.run_action_bar.sig_smart_run_ctrl.connect(self.smart_run_with_data) self.run_action_bar.sig_run_with_data.connect( lambda: self.start_run( diff --git a/src/badger/gui/pages/home_page.py b/src/badger/gui/pages/home_page.py index c4e054de..8d97ede0 100644 --- a/src/badger/gui/pages/home_page.py +++ b/src/badger/gui/pages/home_page.py @@ -280,7 +280,6 @@ def config_logic(self): self.run_monitor.jump_to_optimal ) self.run_action_bar.sig_dial_in.connect(self.run_monitor.set_vars) - self.run_action_bar.sig_ctrl.connect(self.run_monitor.ctrl_routine) self.run_action_bar.sig_open_extensions_palette.connect( self.run_monitor.open_extensions_palette ) From bba3c1b9b1e63010a10eabd92f603d7b34ff9aeb Mon Sep 17 00:00:00 2001 From: michaellans Date: Wed, 16 Sep 2026 11:19:35 -0700 Subject: [PATCH 33/36] handle close while paused --- src/badger/gui/pages/home_page.py | 4 ++++ src/badger/gui/windows/main_window.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/badger/gui/pages/home_page.py b/src/badger/gui/pages/home_page.py index 8d97ede0..c502f4c5 100644 --- a/src/badger/gui/pages/home_page.py +++ b/src/badger/gui/pages/home_page.py @@ -538,6 +538,10 @@ def start_run( """ logger.info("Starting run.") + + if self.run_monitor.running: + self.run_monitor.stop() + # Set data options based on checkbox states from data_panel run_data_flag = load_displayed_data or self.data_panel.use_data init_points_flag = self.data_panel.init_points diff --git a/src/badger/gui/windows/main_window.py b/src/badger/gui/windows/main_window.py index 6b92fd73..665ec18f 100644 --- a/src/badger/gui/windows/main_window.py +++ b/src/badger/gui/windows/main_window.py @@ -118,6 +118,11 @@ def closeEvent(self, event) -> None: self.process_manager.close_proccesses() monitor.destroy_unused_env() return + elif monitor.paused: + monitor.routine_runner.stop_routine() + self.process_manager.close_proccesses() + monitor.destroy_unused_env() + return reply = QMessageBox.question( self, From 21b5047fcc8e78917ad9cff1e6e16e4c6e52f808 Mon Sep 17 00:00:00 2001 From: michaellans Date: Thu, 17 Sep 2026 10:14:09 -0700 Subject: [PATCH 34/36] fix pre-existing bug with close behavior while routine is running --- src/badger/gui/mini/windows/main_window.py | 11 +++-------- src/badger/gui/windows/main_window.py | 11 +++-------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/badger/gui/mini/windows/main_window.py b/src/badger/gui/mini/windows/main_window.py index 1a559b79..4106f67b 100644 --- a/src/badger/gui/mini/windows/main_window.py +++ b/src/badger/gui/mini/windows/main_window.py @@ -122,14 +122,9 @@ def closeEvent(self, event) -> None: ) if reply == QMessageBox.Yes: - - def close_window(): - monitor.destroy_unused_env() - self.close() - - monitor.register_post_run_action(close_window) - monitor.testing = True # suppress the archive pop-ups monitor.routine_runner.stop_routine() - event.ignore() + self.process_manager.close_proccesses() + monitor.destroy_unused_env() + return else: event.ignore() diff --git a/src/badger/gui/windows/main_window.py b/src/badger/gui/windows/main_window.py index 665ec18f..061989a5 100644 --- a/src/badger/gui/windows/main_window.py +++ b/src/badger/gui/windows/main_window.py @@ -134,14 +134,9 @@ def closeEvent(self, event) -> None: ) if reply == QMessageBox.Yes: - - def close_window(): - monitor.destroy_unused_env() - self.close() - - monitor.register_post_run_action(close_window) - monitor.testing = True # suppress the archive pop-ups monitor.routine_runner.stop_routine() - event.ignore() + self.process_manager.close_proccesses() + monitor.destroy_unused_env() + return else: event.ignore() From a0427ad7287a929a17e505f585de47966cb83377 Mon Sep 17 00:00:00 2001 From: michaellans Date: Thu, 17 Sep 2026 10:16:48 -0700 Subject: [PATCH 35/36] linting --- src/badger/gui/pages/home_page.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/badger/gui/pages/home_page.py b/src/badger/gui/pages/home_page.py index c502f4c5..602cae13 100644 --- a/src/badger/gui/pages/home_page.py +++ b/src/badger/gui/pages/home_page.py @@ -541,7 +541,7 @@ def start_run( if self.run_monitor.running: self.run_monitor.stop() - + # Set data options based on checkbox states from data_panel run_data_flag = load_displayed_data or self.data_panel.use_data init_points_flag = self.data_panel.init_points From d760f2c663fcb0e3a3f6b89422e48238bec8b17e Mon Sep 17 00:00:00 2001 From: michaellans Date: Thu, 17 Sep 2026 15:36:44 -0700 Subject: [PATCH 36/36] add tests for run_controller and update test_pause_play to remove btn_ctrl --- src/badger/tests/test_run_controller.py | 161 ++++++++++++++++++++++++ src/badger/tests/test_run_monitor.py | 19 +-- 2 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 src/badger/tests/test_run_controller.py diff --git a/src/badger/tests/test_run_controller.py b/src/badger/tests/test_run_controller.py new file mode 100644 index 00000000..f2c61fbe --- /dev/null +++ b/src/badger/tests/test_run_controller.py @@ -0,0 +1,161 @@ +from PyQt5.QtTest import QSignalSpy + + +class TestSmartRunController: + @staticmethod + def create_controller(): + # Create a fresh controller so each test starts without prior run state. + from badger.gui.mini.components.run_controller import SmartRunController + + return SmartRunController() + + def test_pauses_active_run(self, qtbot): + # An active, unpaused run should pause instead of stopping or restarting. + controller = self.create_controller() + pause_spy = QSignalSpy(controller.sig_pause_ctrl) + stop_spy = QSignalSpy(controller.sig_stop) + start_spy = QSignalSpy(controller.sig_start) + + controller.smart_run({"name": "routine"}, True, False, False) + + # The pause spy captures the requested pause state; no restart signals are allowed. + assert len(pause_spy) == 1 + assert pause_spy[0][0] is True + assert len(stop_spy) == 0 + assert len(start_spy) == 0 + + def test_resumes_unchanged_paused_run(self, qtbot): + # Pressing run for the unchanged paused routine should unpause it. + controller = self.create_controller() + routine = {"name": "routine"} + controller.last_routine_dict = routine + pause_spy = QSignalSpy(controller.sig_pause_ctrl) + + controller.smart_run(routine, True, True, False) + + # A single False payload confirms that the paused run was resumed. + assert len(pause_spy) == 1 + assert pause_spy[0][0] is False + + def test_restarts_unchanged_finished_run_with_data(self, qtbot): + # An unchanged routine with no active process should restart using its data. + controller = self.create_controller() + routine = {"name": "routine"} + controller.last_routine_dict = routine + start_spy = QSignalSpy(controller.sig_start) + + controller.smart_run(routine, False, False, False) + + # The start signal's True payload means the previous displayed data is reused. + assert len(start_spy) == 1 + assert start_spy[0][0] is True + assert controller.last_routine_dict == routine + + def test_queues_compatible_restart_with_data(self, qtbot): + # A compatible edit during a paused run should stop first, then restart with data. + controller = self.create_controller() + controller.last_routine_dict = {"name": "old"} + stop_spy = QSignalSpy(controller.sig_stop) + start_spy = QSignalSpy(controller.sig_start) + + controller.smart_run({"name": "new"}, True, True, True) + + # Stop is emitted first, while the flags prove the compatible restart is queued with data. + assert len(stop_spy) == 1 + assert len(start_spy) == 0 + assert controller._pending_start is True + assert controller._load_data is True + + controller.notify_routine_finished() + + # Completion releases the queued start and carries the data-loading choice through the signal. + assert len(start_spy) == 1 + assert start_spy[0][0] is True + assert controller._pending_start is False + assert controller.last_routine_dict == {"name": "new"} + + def test_starts_compatible_finished_run_with_data(self, qtbot): + # A compatible edit after a run ends can start immediately with existing data. + controller = self.create_controller() + controller.last_routine_dict = {"name": "old"} + start_spy = QSignalSpy(controller.sig_start) + + controller.smart_run({"name": "new"}, False, False, True) + + # With no active process, a compatible restart starts immediately and requests displayed data. + assert len(start_spy) == 1 + assert start_spy[0][0] is True + + def test_queues_incompatible_restart_without_data(self, qtbot): + # An incompatible edit during a paused run should queue a fresh restart. + controller = self.create_controller() + controller.last_routine_dict = {"name": "old"} + stop_spy = QSignalSpy(controller.sig_stop) + start_spy = QSignalSpy(controller.sig_start) + + controller.smart_run({"name": "new"}, True, True, False) + + # Stop is emitted while pending state records that this incompatible restart must start fresh. + assert len(stop_spy) == 1 + assert len(start_spy) == 0 + assert controller._pending_start is True + assert controller._load_data is False + + controller.notify_routine_finished() + + # The queued restart emits False, proving that old displayed data is not loaded. + assert len(start_spy) == 1 + assert start_spy[0][0] is False + + def test_starts_incompatible_finished_run_without_data(self, qtbot): + # An incompatible edit after a run ends should start without previous data. + controller = self.create_controller() + controller.last_routine_dict = {"name": "old"} + start_spy = QSignalSpy(controller.sig_start) + + controller.smart_run({"name": "new"}, False, False, False) + + # The immediate start uses a False payload because the routine is incompatible. + assert len(start_spy) == 1 + assert start_spy[0][0] is False + + def test_restart_override_starts_fresh_run_and_resets_flag(self, qtbot): + # The override bypasses routine matching and is consumed by the next run request. + controller = self.create_controller() + routine = {"name": "routine"} + controller.last_routine_dict = routine + controller.set_restart_override_flag() + start_spy = QSignalSpy(controller.sig_start) + + controller.smart_run(routine, False, False, True) + + # The override forces a fresh start despite matching routine data, then clears itself. + assert len(start_spy) == 1 + assert start_spy[0][0] is False + assert controller.restart_override_flag is False + + def test_notify_finished_does_nothing_without_pending_run(self, qtbot): + # Completion notifications must not start a run when none was queued. + controller = self.create_controller() + start_spy = QSignalSpy(controller.sig_start) + + controller.notify_routine_finished() + + # An empty start spy confirms that completion is ignored without a queued restart. + assert len(start_spy) == 0 + + def test_start_run_records_new_routine_and_clears_pending_state(self, qtbot): + # Starting a queued run records its routine and clears the pending marker. + controller = self.create_controller() + routine = {"name": "routine"} + controller._new_routine_dict = routine + controller._pending_start = True + start_spy = QSignalSpy(controller.sig_start) + + controller.start_run(True) + + # The signal payload and state fields confirm the queued routine was started with its data. + assert len(start_spy) == 1 + assert start_spy[0][0] is True + assert controller._pending_start is False + assert controller.last_routine_dict == routine diff --git a/src/badger/tests/test_run_monitor.py b/src/badger/tests/test_run_monitor.py index fd220c6d..6537376f 100644 --- a/src/badger/tests/test_run_monitor.py +++ b/src/badger/tests/test_run_monitor.py @@ -286,24 +286,29 @@ def test_y_axis_specification(self, qtbot, monitor): def test_pause_play(self, qtbot, home_page): monitor = home_page.run_monitor - action_bar = home_page.run_action_bar - monitor.termination_condition = { "tc_idx": 0, "max_eval": 10, } spy = QSignalSpy(monitor.sig_pause) + # Start a real run so pause and resume are tested against an active monitor. monitor.start(True) - # qtbot.wait(500) + qtbot.wait(500) - qtbot.mouseClick(action_bar.btn_ctrl, Qt.MouseButton.LeftButton) + monitor.ctrl_routine(True) + # The state, signal, and cleared event confirm that the active run paused. + assert monitor.paused is True + assert monitor.routine_runner.pause_event.is_set() is False assert len(spy) == 1 + assert spy[0][0] is True - qtbot.wait(500) - - qtbot.mouseClick(action_bar.btn_ctrl, Qt.MouseButton.LeftButton) + monitor.ctrl_routine(False) + # The state, signal, and set event confirm that the active run resumed. + assert monitor.paused is False + assert monitor.routine_runner.pause_event.is_set() is True assert len(spy) == 2 + assert spy[1][0] is False while monitor.running: qtbot.wait(100)