From 8f7633bfb869b6bae069934aa75e9a8b6e4a0158 Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 15 Sep 2026 14:55:54 -0700 Subject: [PATCH 01/30] 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 02/30] 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 03/30] 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 04/30] 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 05/30] 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 06/30] 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 07/30] 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 08/30] 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 09/30] 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 10/30] 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 11/30] 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 12/30] 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 13/30] 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 14/30] 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 15/30] 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 16/30] 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 17/30] 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) From 9b62e01660cb7f22e1dd8b74921602332cb8ef16 Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 29 Sep 2026 11:01:34 -0700 Subject: [PATCH 18/30] update run button signals, text, and menu icons --- src/badger/gui/components/action_bar.py | 213 ++++++++++++++++-------- src/badger/gui/images/play_time.png | Bin 0 -> 136076 bytes 2 files changed, 139 insertions(+), 74 deletions(-) create mode 100644 src/badger/gui/images/play_time.png diff --git a/src/badger/gui/components/action_bar.py b/src/badger/gui/components/action_bar.py index 4d039e23..52fac650 100644 --- a/src/badger/gui/components/action_bar.py +++ b/src/badger/gui/components/action_bar.py @@ -3,8 +3,8 @@ 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 QEvent, pyqtSignal, QSize +from PyQt5.QtGui import QIcon, QFont, QPainter, QPalette +from PyQt5.QtCore import QEvent, Qt, pyqtSignal, QSize from importlib import resources from badger.gui.utils import create_button from badger.gui.windows.docs_window import BadgerDocsWindow @@ -25,6 +25,48 @@ def __init__(self, menu_tooltip="", parent=None): """ super().__init__(parent) self.menu_tooltip = menu_tooltip + self.display_text = None # show next termination condition + self._last_display_text = None # used to show/hide text while running + + font = QFont() + font.setWeight(QFont.Normal) + font.setPixelSize(10) + self.setFont(font) + + def setDisplayText(self, text): + self.display_text = text + self.update() + + def setDefaultAction(self, action): + super().setDefaultAction(action) + self.setIcon(action.icon()) + self.update() + + def hide_text(self): + self._last_display_text = self.display_text + self.setDisplayText("") + + def show_text(self): + self.setDisplayText(self._last_display_text) + + def initStyleOption(self, option): + super().initStyleOption(option) + if self.display_text is not None: + option.text = self.display_text + + def paintEvent(self, event): + option = QStyleOptionToolButton() + self.initStyleOption(option) + painter = QPainter(self) + option.text = "" + option.toolButtonStyle = Qt.ToolButtonIconOnly + self.style().drawComplexControl(QStyle.CC_ToolButton, option, painter, self) + painter.setFont(self.font()) + painter.setPen(option.palette.color(QPalette.ButtonText)) + text_rect = self.rect().adjusted(self.width() // 2 - 4, 14, -8, 0) + painter.drawText( + text_rect, Qt.AlignLeft | Qt.AlignVCenter, self.display_text or "" + ) def _over_menu_arrow(self, pos): opt = QStyleOptionToolButton() @@ -101,7 +143,7 @@ def event(self, event): QToolButton { background-color: #4AB640; - color: #000000; + color: #FFFFFF; } """ @@ -123,11 +165,11 @@ def event(self, event): class BadgerActionBar(QWidget): sig_start = pyqtSignal() - sig_start_until = pyqtSignal( + sig_start_until = pyqtSignal( # I think this is now deprecated bool ) # bool True launches termination condition dialog menu sig_stop = pyqtSignal() - + sig_flag_restart = pyqtSignal() sig_delete_run = pyqtSignal() sig_logbook = pyqtSignal() sig_reset_env = pyqtSignal() @@ -137,6 +179,8 @@ class BadgerActionBar(QWidget): sig_run_with_data = pyqtSignal() sig_smart_run_ctrl = pyqtSignal() sig_open_extensions_palette = pyqtSignal() + # signal to home_page to open termination edit dialog + sig_update_tc = pyqtSignal() sig_save_checkpoint = pyqtSignal() sig_edit_checkpoint = pyqtSignal() @@ -156,6 +200,7 @@ def load_internal_icon(name: str) -> QIcon: return QIcon(str(icon_path)) self.icon_play = load_internal_icon("play.png") + self.icon_play_time = load_internal_icon("play_time.png") self.icon_pause = load_internal_icon("pause.png") self.icon_stop = load_internal_icon("stop.png") self.icon_flag = load_internal_icon("flag.png") @@ -172,10 +217,6 @@ def load_internal_icon(name: str) -> QIcon: hbox_bg = QHBoxLayout(self.bg) hbox_bg.setContentsMargins(8, 8, 8, 8) - cool_font = QFont() - cool_font.setWeight(QFont.DemiBold) - cool_font.setPixelSize(13) - self.btn_del = create_button("trash.png", "Delete run", stylesheet_del) self.btn_log = create_button("book.png", "Logbook", stylesheet_log) self.btn_help = create_button("help_btn.png", "Open Docs", stylesheet_log) @@ -198,7 +239,6 @@ def load_internal_icon(name: str) -> QIcon: # self.btn_stop = btn_stop = QPushButton('Run') 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) # add button for extensions @@ -230,31 +270,49 @@ 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 (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) - 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) - 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) + + # TODO: This is quite clunky, the run button (btn_stop) should really have + # its own class with action/signal/ui logic. 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) + # In 'mini mode', the action is always smart_run_action. Selecting either + # "New Run" or "Edit Condition" from the menu will emit signals to + # perform the associated actions, but do not update the default + self.run_action = run_action = QAction("New Run", 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_time) + self.run_until_menu_action = run_until_menu_action = QAction( + "Edit Condition", self + ) + run_until_menu_action.setIcon(self.icon_play_time) + self.smart_run_action = smart_run_action = QAction("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) + else: + # In non-mini mode, selecting "Run until" or "Run" from the + # menu will update the default run action. + self.run_action = run_action = QAction("New Run", 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_time) + self.run_until_menu_action = run_until_menu_action = QAction( + "Run until", self + ) + run_until_menu_action.setIcon(self.icon_play_time) + self.smart_run_action = smart_run_action = QAction( + "Smart Run", self + ) # not used in main gui + smart_run_action.setIcon(self.icon_play) # not used in main gui + 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) # Set the menu as the run button's dropdown menu self.btn_stop.setMenu(menu) @@ -304,9 +362,6 @@ def config_logic(self): ) 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() - ) self.save_checkpoint_action.triggered.connect( lambda: self.sig_save_checkpoint.emit() ) @@ -343,16 +398,15 @@ 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 (restart)") + self.run_action.setText("New Run") 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.run_until_menu_action.setIcon(self.icon_play_time) self.smart_run_action.setIcon(self.icon_play) - # self.btn_stop.setToolTip('') + self.run_until_menu_action.setText("Run until") + self.smart_run_action.setText("Run") + self.btn_stop.show_text() self.btn_stop.setDisabled(False) + self.update_stop_menu(True) self.btn_reset.setDisabled(False) self.btn_set.setDisabled(False) @@ -384,27 +438,26 @@ def run_start(self): self.smart_run_action.setIcon(self.icon_pause) self.btn_checkpoint.setDisabled(False) self.btn_set.setDisabled(True) + self.update_stop_menu(False) 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 (restart)": - self.btn_stop.setDisabled(True) - self.sig_start.emit() + if self.mini_mode: + # run action "New Run" sets flag to restart in run_controller + self.sig_flag_restart.emit() + self.sig_smart_run_ctrl.emit() else: - self.btn_stop.setDisabled(True) - self.sig_stop.emit() + if self.btn_stop.defaultAction() is not self.run_action: + self.btn_stop.setDefaultAction(self.run_action) - 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_action.text() == "Stop": + self.btn_stop.setDisabled(True) + self.sig_stop.emit() + else: + self.btn_stop.setDisabled(True) + self.sig_start.emit() - if self.run_until_action.text() == "Run until": - self.sig_start_until.emit(from_menu) - else: - self.btn_stop.setDisabled(True) - self.sig_stop.emit() + def set_run_until_action(self, from_menu=False): + self.sig_update_tc.emit() def set_smart_run_action(self): if self.btn_stop.defaultAction() is not self.smart_run_action: @@ -448,14 +501,17 @@ def handle_pause_action(self, status: bool): Enable/disable buttons for pause (true)/resume (false) optimization """ if status: + # paused self.btn_stop.setStyleSheet(stylesheet_run) self.smart_run_action.setIcon(self.icon_play) + self.run_until_menu_action.setIcon(self.icon_play_time) self.btn_stop.setDisabled(False) self.btn_reset.setDisabled(False) self.btn_set.setDisabled(False) self.btn_del.setDisabled(False) else: + # running self.btn_stop.setStyleSheet(stylesheet_stop) self.smart_run_action.setIcon(self.icon_pause) self.btn_stop.setDisabled(False) @@ -469,22 +525,22 @@ 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.setText("New Run") 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) + if self.mini_mode: + self.run_until_menu_action.setText("Edit Condition") + else: + self.run_until_menu_action.setText("Run until") + self.smart_run_action.setText("Run") 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) + self.btn_stop.show_text() else: self.run_menu.clear() self.run_menu.addAction(self.stop_run_action) - self.run_menu.addAction(self.smart_run_action) + if self.mini_mode: + self.run_menu.addAction(self.smart_run_action) + self.btn_stop.hide_text() def open_extensions_palette(self): self.sig_open_extensions_palette.emit() @@ -493,15 +549,24 @@ 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.""" + def update_run_tooltip(self, tc: dict | None = None): + """Update btn_stop tooltip with next termination condition""" + tc_text = "" 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')}" + tc_text = tc.get("max_eval", "") + tip = f"Run until: n iterations = {tc_text}" + tc_text = f"+{tc.get('max_eval', '')}" elif tc_idx == 1: - tip = f"Run until: timeout = {tc.get('max_time')}s" + tc_text = f"{int(tc.get('max_time', 0))} s" + tip = f"Run until: timeout = {tc_text}" self.run_until_action.setToolTip(tip) self.run_until_menu_action.setToolTip(tip) + + self.update_run_button_text(str(tc_text)) + + def update_run_button_text(self, text: str) -> None: + self.btn_stop.setDisplayText(text) diff --git a/src/badger/gui/images/play_time.png b/src/badger/gui/images/play_time.png new file mode 100644 index 0000000000000000000000000000000000000000..b2a324d72f35f178e8f897e73da5dd14f52e0e0d GIT binary patch literal 136076 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

3LZ;!>{J%^g2N!S(x%8y_K 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 iB(Nlb|2GMAFZaFj Date: Tue, 29 Sep 2026 17:06:53 -0700 Subject: [PATCH 19/30] Update termination_condition handling with pause acknowledgement from subprocess, extend condition on resume --- src/badger/core_subprocess.py | 221 +++++++++++------- src/badger/errors.py | 4 + src/badger/gui/components/create_process.py | 3 + src/badger/gui/components/routine_runner.py | 55 +++-- src/badger/gui/components/run_monitor.py | 50 +++- src/badger/gui/mini/pages/home_page.py | 53 +++-- src/badger/gui/pages/home_page.py | 30 ++- .../windows/terminition_condition_dialog.py | 48 +++- 8 files changed, 306 insertions(+), 158 deletions(-) diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index a9ad7abc..df295b9d 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -32,9 +32,7 @@ MEASUREMENT_ACTION_RETRY, MEASUREMENT_ACTION_ABORT, TERMINATION_REACHED_TYPE, - TERMINATION_ACTION_TYPE, - TERMINATION_ACTION_CONTINUE, - TERMINATION_ACTION_END, + PAUSE_ACK_TYPE, ) from badger.logger import _get_default_logger from badger.logger.event import Events @@ -94,59 +92,23 @@ 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 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. + Pause the run and notify the GUI if so. + + Returns + ------- + bool + True if termination conditon has been reached, otherwise False. If True + clears pause_process so that the optimization loop will pause at the next + check """ if not termination_condition or not start_time: return False @@ -166,20 +128,20 @@ def check_termination_condition( count = 0 if count >= max_eval: - logger.info( - "Max evaluations reached. Pausing optimization and waiting for user action." - ) + logger.info("Max evaluations reached. Pausing optimization.") + # clear pause_process. This pauses the optimizaiton loop at the + # next 'if not pause_process.is_set()' check) 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, - }, + # Notify GUI that termination has been reached + queue.put( + { + "type": TERMINATION_REACHED_TYPE, + "tc_condition": { + "type": "max_eval", + "config": max_eval, + "state": count, + }, + } ) return True elif idx == 1: @@ -187,26 +149,100 @@ def check_termination_condition( 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." - ) + logger.info("Max time reached. Pausing optimization.") + # clear pause_process. This pauses the optimizaiton loop at the + # next 'if not pause_process.is_set()' check) 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, - }, + # Notify GUI that termination has been reached + queue.put( + { + "type": TERMINATION_REACHED_TYPE, + "tc_condition": { + "type": "max_time", + "config": max_time, + "state": dt, + }, + } ) return True return False +def extend_termination_from_current( + updated_condition: dict, routine: Routine, start_time: float +): + """Extend a termination condition by updated_condition from the current routine state. + + Parameters + ---------- + updated_condition : dict + tc_config dictionary to extend by + routine : Routine + Routine used to determine the current evaluation count. + start_time : float + Start time for time-based termination conditions. + + Returns + ------- + dict + A termination condition dict of the current state extended by updated_condition. + """ + termination_condition = deepcopy(updated_condition) + if termination_condition["tc_idx"] == 0: + if routine.data is not None and "live" in routine.data.columns: + current_state = sum(live_val == 1 for live_val in routine.data["live"]) + else: + current_state = len(routine.data) if routine.data is not None else 0 + termination_condition["max_eval"] += current_state + elif termination_condition["tc_idx"] == 1: + termination_condition["max_time"] += time.time() - start_time + + return termination_condition + + +def check_for_extension( + termination_condition, + queue, + termination_control_queue, + routine, + start_time, +): + """ + Checks termination_control_queue for an updated termination condition from the GUI. + This is expected to be called after the subprocess resumes following a pause, so that + it can get updated condition from GUI + + Returns + ------- + dict or None + The updated termination condition, or None if cleared. If there's no message, returns the + current condition + """ + while True: + try: + msg = termination_control_queue.get(timeout=0.1) + except Empty: + return termination_condition + if isinstance(msg, dict) and msg.get("type") == "extend_termination_condition": + updated_condition = msg.get("termination_condition") + if updated_condition is None: + termination_condition = None + else: + termination_condition = extend_termination_from_current( + updated_condition, routine, start_time + ) + # return updated termination condition to GUI + queue.put( + { + "type": "termination_extended", + "termination_condition": termination_condition, + } + ) + return termination_condition + logger.warning("Ignoring unexpected resume message: %r", msg) + + 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. @@ -259,15 +295,16 @@ def convert_to_solution(result: DataFrame, routine: Routine): def run_routine_subprocess( - args_queue: mp.Queue, + args_queue: mp.Queue, # receive startup args from gui queue: mp.Queue, evaluate_queue: mp.Pipe, stop_process: mp.Event, - pause_process: mp.Event, + pause_process: mp.Event, # pause optimization loop wait_event: mp.Event, config_path: str = None, log_queue: mp.Queue = None, - dialog_action_queue: mp.Queue = None, + dialog_action_queue: mp.Queue = None, # receive measurement retry dialog user input + termination_control_queue: mp.Queue = None, # receive termination_condition updates from gui ) -> None: """ Run the provided routine object using Xopt. This method is run as a subproccess @@ -420,28 +457,33 @@ def run_routine_subprocess( logger.info("Stop process set. Terminating optimization.") evaluate_queue[0].close() raise BadgerRunTerminated - elif not pause_process.is_set(): - logger.info("Pause process not set. Waiting...") - pause_process.wait() + # check termination_condition before checking pause_process 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 - # ) + # clear condition so routine can resume; note that check_termination_condition + # will clear pause_process if it returns true, and so the loop will pause + # at the next 'if not pause_process.is_set()' check below termination_condition = None - queue.put( - { - "type": "termination_extended", - "termination_condition": termination_condition, - } + + if not pause_process.is_set(): + logger.info("Pause process not set. Waiting...") + queue.put({"type": PAUSE_ACK_TYPE}) # send pause acknowledgement to gui + pause_process.wait() + + # On resume, check for a new termination condition + # and clear or extend + termination_condition = check_for_extension( + termination_condition, + queue, + termination_control_queue, + routine, + start_time, ) continue @@ -457,6 +499,7 @@ def run_routine_subprocess( logger.info( "Pause process not set during optimization loop. Waiting..." ) + queue.put({"type": PAUSE_ACK_TYPE}) # acknowledge pause pause_process.wait() result = evaluate_measurement_with_retry( diff --git a/src/badger/errors.py b/src/badger/errors.py index 02a575d4..5c7340b9 100644 --- a/src/badger/errors.py +++ b/src/badger/errors.py @@ -128,3 +128,7 @@ def __init__(self, message="Optimization run has been terminated!"): TERMINATION_ACTION_TYPE = "termination_action" TERMINATION_ACTION_CONTINUE = "continue" TERMINATION_ACTION_END = "end" + +# Sent by the subprocess right before pause_process.wait(), +# so the GUI knows when pause has been reached +PAUSE_ACK_TYPE = "pause_ack" diff --git a/src/badger/gui/components/create_process.py b/src/badger/gui/components/create_process.py index b32b3e35..702ec81f 100644 --- a/src/badger/gui/components/create_process.py +++ b/src/badger/gui/components/create_process.py @@ -38,6 +38,7 @@ def create_subprocess(self) -> None: self.evaluate_queue = Pipe() self.wait_event = Event() self.dialog_action_queue = Queue() + self.termination_control_queue = Queue() config_path = init_settings()._instance.config_path # Get the logging queue from the centralized manager @@ -57,6 +58,7 @@ def create_subprocess(self) -> None: config_path, log_queue, self.dialog_action_queue, + self.termination_control_queue, ), ) new_process.start() @@ -70,6 +72,7 @@ def create_subprocess(self) -> None: "evaluate_queue": self.evaluate_queue, "wait_event": self.wait_event, "dialog_action_queue": self.dialog_action_queue, + "termination_control_queue": self.termination_control_queue, } ) self.finished.emit() diff --git a/src/badger/gui/components/routine_runner.py b/src/badger/gui/components/routine_runner.py index 71db85be..fdb1c1a5 100644 --- a/src/badger/gui/components/routine_runner.py +++ b/src/badger/gui/components/routine_runner.py @@ -23,18 +23,13 @@ MEASUREMENT_ACTION_RETRY, MEASUREMENT_ACTION_ABORT, TERMINATION_REACHED_TYPE, - TERMINATION_ACTION_TYPE, - TERMINATION_ACTION_CONTINUE, - TERMINATION_ACTION_END, + PAUSE_ACK_TYPE, ) 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__) @@ -48,6 +43,8 @@ class BadgerRoutineSignals(QObject): info = pyqtSignal(str) states = pyqtSignal(str) sig_status = pyqtSignal(str) # status message information + sig_termination_reached = pyqtSignal(dict) # tc condition that paused the run + sig_pause_ack = pyqtSignal() # subprocess has actually stopped after a pause request class BadgerRoutineSubprocess: @@ -178,6 +175,9 @@ def run(self, run_data_flag: bool = False, init_points_flag: bool = False) -> No self.evaluate_queue = process_with_args["evaluate_queue"] self.wait_event = process_with_args["wait_event"] self.dialog_action_queue = process_with_args["dialog_action_queue"] + self.termination_control_queue = process_with_args[ + "termination_control_queue" + ] arg_dict = { "routine_id": self.routine.id, @@ -276,13 +276,9 @@ def check_queue(self) -> None: 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, - } - ) + self.handle_termination_reached(msg) + elif isinstance(msg, dict) and msg.get("type") == PAUSE_ACK_TYPE: + self.signals.sig_pause_ack.emit() elif ( isinstance(msg, dict) and msg.get("type") == "termination_extended" ): @@ -308,22 +304,12 @@ def handle_measurement_error(self, msg: dict) -> str: return MEASUREMENT_ACTION_RETRY return MEASUREMENT_ACTION_ABORT - def handle_termination_reached(self, msg: dict) -> str: - # update status + def handle_termination_reached(self, msg: dict) -> None: + """The subprocess already paused itself, so only report the new state.""" + self.active_tc = None 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=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 + self.signals.sig_status.emit(self._format_tc_status_str(tc_condition)) + self.signals.sig_termination_reached.emit(tc_condition) def _format_tc_status_str(self, tc_condition: dict) -> str: tc_type = tc_condition["type"] @@ -383,12 +369,23 @@ 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 resume_with_extension(self) -> None: + """Extend the active condition by the configured amount and resume.""" + # send new termination condition to subprocess + self.termination_control_queue.put( + { + "type": "extend_termination_condition", + "termination_condition": self.termination_condition, + } + ) + + self.ctrl_routine(False) # unpause + def close(self) -> None: logger.info("Closing routine subprocess and stopping timer.") self.timer.stop() diff --git a/src/badger/gui/components/run_monitor.py b/src/badger/gui/components/run_monitor.py index bb63e535..3434c5d7 100644 --- a/src/badger/gui/components/run_monitor.py +++ b/src/badger/gui/components/run_monitor.py @@ -72,6 +72,11 @@ class BadgerOptMonitor(QWidget): sig_toggle_run = pyqtSignal(bool) sig_toggle_other = pyqtSignal(bool) sig_env_ready = pyqtSignal() + sig_env_reset = pyqtSignal() # notify on reset complete + sig_vars_set = pyqtSignal() # notify var set complete + + sig_paused = pyqtSignal(bool) # Notify home_page that run has been paused to make sure GUI updates + sig_termination_reached = pyqtSignal(dict) # run paused by a run-until condition def __init__(self, process_manager: "Optional[ProcessManager]" = None): super().__init__() @@ -456,6 +461,13 @@ def init_routine_runner(self) -> None: 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) + routine_runner.signals.sig_termination_reached.connect( + lambda: self.set_paused(True) + ) + routine_runner.signals.sig_termination_reached.connect( + self.sig_termination_reached.emit + ) + routine_runner.signals.sig_pause_ack.connect(lambda: self.set_paused(True)) self.sig_pause.connect(routine_runner.ctrl_routine) self.sig_stop.connect(routine_runner.stop_routine) @@ -468,7 +480,6 @@ def reset_routine_runner(self) -> None: def start( self, - use_termination_condition: bool = False, run_data_flag: bool = False, init_points_flag: bool = True, ) -> None: @@ -477,9 +488,9 @@ def start( if not run_data_flag: self.routine.data = None # reset data if any self.init_plots(self.routine) + # routine runner initialized self.init_routine_runner() - if use_termination_condition: - self.routine_runner.set_termination_condition(self.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( @@ -490,6 +501,8 @@ def start( def save_termination_condition(self, tc) -> None: self.termination_condition = tc + if self.routine_runner: # will be None on startup + self.routine_runner.set_termination_condition(tc) def enable_auto_range(self) -> None: # Enable autorange @@ -542,6 +555,9 @@ def update(self, results: pd.DataFrame) -> None: self.check_critical() def update_status_with_tc(self): + if self.paused: + return + termination_condition = self.routine_runner.active_tc if termination_condition: idx = self.termination_condition["tc_idx"] @@ -753,9 +769,35 @@ def logbook(self) -> None: # QMessageBox.information( # self, 'Success!', f'') + def set_paused(self, paused: bool) -> None: + """ + Record the pause state of the run, and reflect on the GUI. This can be called either from + a GUI action or if the subprocess pauses after reaching a stopping condition. + """ + self.paused = paused + if paused: + self.sig_status.emit(f"Routine {self.routine.name} paused") + self.sig_paused.emit(paused) + def ctrl_routine(self, status) -> None: - self.paused = status + """Called from home_page to pause(True)/unpause(False) subprocess loop. + + On pause, the GUI is not reflected as paused until the subprocess confirms + it has actually stopped (see sig_pause_ack), since it may still be mid-evaluation. + """ + if not status: + self.set_paused(False) self.sig_pause.emit(status) + + def resume_with_extension(self) -> None: + """ + Resume the active routine and extend the configured termination condition. + Updates the UI and calls routine_runner.resume_with_extension which + places the currently configured termination condition extension in + a queue to the subprocess to extend on resuming. + """ + self.set_paused(False) + self.routine_runner.resume_with_extension() def ins_obj_dragged(self, ins_obj) -> None: self.inspector_variable.setValue(ins_obj.value()) diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index 6a6fe668..4fccf9e8 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -299,14 +299,12 @@ def config_logic(self): ) 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), - load_displayed_data=True, - ) + lambda: self.start_run(load_displayed_data=True) ) self.run_action_bar.sig_open_extensions_palette.connect( self.run_monitor.open_extensions_palette ) + self.run_action_bar.sig_update_tc.connect(self.edit_termination_condition) self.sig_routine_invalid.connect(self.run_action_bar.routine_invalid) @@ -314,7 +312,6 @@ def config_logic(self): 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 ) ) @@ -326,8 +323,8 @@ def _configure_default_run_action(self): self.run_action_bar.btn_stop.setDefaultAction( 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} + # configure default to max_eval (tc_idx=0), 50 iterations + 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) @@ -541,13 +538,39 @@ def smart_run_with_data(self): ) 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) + if pause: + # pause routine + self.run_monitor.ctrl_routine(True) + else: + self.run_monitor.resume_with_extension() + + def reflect_pause_state(self, paused: bool): + """Sync the GUI to the run pause state, whoever initiated it.""" + self.run_action_bar.handle_pause_action(paused) + self.toggle_lock(not paused) + + def edit_termination_condition(self): + dlg = BadgerTerminationConditionDialog( + self, + run_opt=None, + save_config=self.run_monitor.save_termination_condition, + configs=self.run_monitor.termination_condition, + ) + self.tc_dialog = dlg + try: + dlg.exec() + finally: + self.tc_dialog = None + self.run_action_bar.update_run_tooltip( + self.run_monitor.termination_condition + ) + + def termination_updated(self, tc: dict): + self.run_action_bar.update_run_tooltip(tc) def prepare_run(self, data=None, init_points_flag=True): """ - Prepares the run by composing the routine, validating data if present, + Prepares the run by composing the routine, validating data if present, saving created routine to a yaml file, and passing the routine to the run monitor to initialize plots. @@ -605,16 +628,13 @@ 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, load_displayed_data: bool = False - ): + def start_run(self, load_displayed_data: bool = False): """ Prepares and starts optimization run with provided options. - Termination Condition is provided when called via BadgerTerminationConditionDialog - Data Options are collected from BadgerDataPanel 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: @@ -651,7 +671,6 @@ def start_run( self.prepare_run() self.run_monitor.start( - use_termination_condition=use_termination_condition, run_data_flag=run_data_flag, init_points_flag=init_points_flag, ) @@ -687,7 +706,7 @@ def start_run_until(self, dialog: bool = True): self.run_monitor.termination_condition ) else: - self.start_run(use_termination_condition=True) + self.start_run() def new_run(self): logger.info("Creating new run.") diff --git a/src/badger/gui/pages/home_page.py b/src/badger/gui/pages/home_page.py index 6cf6b85c..136a47a1 100644 --- a/src/badger/gui/pages/home_page.py +++ b/src/badger/gui/pages/home_page.py @@ -22,6 +22,7 @@ from PyQt5.QtCore import QModelIndex, Qt, pyqtSignal from PyQt5.QtGui import QIcon, QKeySequence from PyQt5.QtWidgets import ( + QDialog, QLabel, QMessageBox, QShortcut, @@ -55,6 +56,7 @@ # from PyQt5.QtGui import QBrush, QColor from badger.gui.windows.message_dialog import BadgerScrollableMessageBox +from badger.gui.windows.termination_reached_dialog import BadgerTerminationReachedDialog from badger.gui.windows.terminition_condition_dialog import ( BadgerTerminationConditionDialog, ) @@ -261,14 +263,12 @@ def config_logic(self) -> None: self.run_monitor.sig_toggle_run.connect(self.run_action_bar.toggle_run) self.run_monitor.sig_toggle_other.connect(self.run_action_bar.toggle_other) self.run_monitor.sig_env_ready.connect(self.run_action_bar.env_ready) + self.run_monitor.sig_termination_reached.connect(self.termination_reached) 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, - ) + lambda: self.start_run(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) @@ -290,6 +290,7 @@ def config_logic(self) -> None: self.run_action_bar.sig_open_extensions_palette.connect( self.run_monitor.open_extensions_palette ) + self.run_action_bar.sig_update_tc.connect(self.start_run_until) self.sig_routine_invalid.connect(self.run_action_bar.routine_invalid) @@ -542,11 +543,7 @@ def prepare_run( # Tell monitor to start the run self.run_monitor.init_plots(routine) - def start_run( - self, - use_termination_condition: bool = False, - load_displayed_data: bool = False, - ) -> None: + def start_run(self, load_displayed_data: bool = False) -> None: """ Prepares and starts optimization run with provided options. - Termination Condition is provided when called via BadgerTerminationConditionDialog @@ -583,11 +580,23 @@ def start_run( self.prepare_run() self.run_monitor.start( - use_termination_condition=use_termination_condition, run_data_flag=run_data_flag, init_points_flag=init_points_flag, ) + def termination_reached(self, tc_condition: dict): + """ + The run has been paused from the subprocess by reaching a termination condition. + Opens a dialog to resume or stop. + """ + dlg = BadgerTerminationReachedDialog(tc_condition=tc_condition, parent=self) + if dlg.exec_() == QDialog.Accepted: + # unpause and continue + self.run_monitor.ctrl_routine(False) + else: + # end run + self.run_monitor.stop() + def start_run_until(self) -> None: logger.info("Starting run until condition met.") dlg = BadgerTerminationConditionDialog( @@ -601,7 +610,6 @@ def start_run_until(self) -> None: dlg.exec() finally: self.tc_dialog = None - # self.run_monitor.start_until() def new_run(self) -> None: logger.info("Creating new run.") diff --git a/src/badger/gui/windows/terminition_condition_dialog.py b/src/badger/gui/windows/terminition_condition_dialog.py index 57a1c0a9..500c06d6 100644 --- a/src/badger/gui/windows/terminition_condition_dialog.py +++ b/src/badger/gui/windows/terminition_condition_dialog.py @@ -2,6 +2,9 @@ when an optimization run should automatically stop — either after a maximum number of evaluations or after a maximum elapsed time.""" +from copy import copy +from collections.abc import Callable + from PyQt5.QtWidgets import ( QDialog, QWidget, @@ -38,12 +41,20 @@ class BadgerTerminationConditionDialog(QDialog): - def __init__(self, parent, run_opt, save_config, configs=None): + def __init__( + self, + parent, + run_opt: Callable | None, + save_config: Callable, + configs: dict | None = None, + ): super().__init__(parent) + # run_opt optional, if function given will be called on set/run button press + # otherwise the dialog will set the config using save_config and close self.run_opt = run_opt self.save_config = save_config - self.configs = configs + self.configs = copy(configs) if configs is None: self.configs = {"tc_idx": 0, "max_eval": 42, "max_time": 600, "ftol": 0} @@ -65,11 +76,14 @@ def init_ui(self): lbl.setFixedWidth(64) self.cb = cb = QComboBox() cb.setItemDelegate(QStyledItemDelegate()) + # TODO: Re-add optimization converged when convergence criteria implemented + # also update the index to set in run() below cb.addItems( [ - "maximum evaluation reached", + "n iterations reached", "maximum running time exceeded", # 'optimization converged', + "None (continue until stopped)", ] ) cb.setCurrentIndex(self.configs["tc_idx"]) @@ -121,8 +135,16 @@ def init_ui(self): hbox_tol.addWidget(lbl) hbox_tol.addWidget(sb_tol, 1) + none_config = QWidget() + hbox_none = QHBoxLayout(none_config) + hbox_none.setContentsMargins(0, 0, 0, 0) + lbl = QLabel("The run will continue until you stop it manually.") + lbl.setWordWrap(True) + hbox_none.addWidget(lbl) + stacks.addWidget(max_eval_config) stacks.addWidget(max_time_config) + stacks.addWidget(none_config) stacks.addWidget(tol_config) stacks.setCurrentIndex(self.configs["tc_idx"]) @@ -134,8 +156,12 @@ def init_ui(self): hbox_set = QHBoxLayout(button_set) hbox_set.setContentsMargins(0, 0, 0, 0) self.btn_cancel = btn_cancel = QPushButton("Cancel") - self.btn_run = btn_run = QPushButton("Run") - btn_run.setStyleSheet(stylesheet_run) + if self.run_opt: + self.btn_run = btn_run = QPushButton("Run") + btn_run.setStyleSheet(stylesheet_run) + else: + self.btn_run = btn_run = QPushButton("Set") + # btn_run.setStyleSheet(stylesheet_run) btn_cancel.setFixedSize(96, 24) btn_run.setFixedSize(96, 24) hbox_set.addStretch() @@ -164,8 +190,14 @@ def ftol_changed(self, ftol): self.configs["ftol"] = ftol def run(self): - self.save_config(self.configs) - self.run_opt(True) + if self.cb.currentIndex() == 2: + # This will need to be updated when ftol is implemented + self.save_config(None) + else: + self.save_config(self.configs) + if self.run_opt: + # only run if given run_opt, otherwise just sets default + self.run_opt() self.close() def terminition_condition_changed(self, i): @@ -175,6 +207,6 @@ def terminition_condition_changed(self, i): self.configs["tc_idx"] = i def closeEvent(self, event): - self.save_config(self.configs) + # self.save_config(self.configs) event.accept() From adbbaefe336812cfbdbb85d911d156d3c049d532 Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 29 Sep 2026 17:16:50 -0700 Subject: [PATCH 20/30] synchronize run controls better with subprocess state --- src/badger/gui/components/routine_runner.py | 6 +++-- src/badger/gui/components/run_monitor.py | 23 ++++++++++------- src/badger/gui/mini/pages/home_page.py | 28 +++++++++++++-------- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/badger/gui/components/routine_runner.py b/src/badger/gui/components/routine_runner.py index fdb1c1a5..3619f9d2 100644 --- a/src/badger/gui/components/routine_runner.py +++ b/src/badger/gui/components/routine_runner.py @@ -44,7 +44,9 @@ class BadgerRoutineSignals(QObject): states = pyqtSignal(str) sig_status = pyqtSignal(str) # status message information sig_termination_reached = pyqtSignal(dict) # tc condition that paused the run - sig_pause_ack = pyqtSignal() # subprocess has actually stopped after a pause request + sig_pause_ack = ( + pyqtSignal() + ) # subprocess has actually stopped after a pause request class BadgerRoutineSubprocess: @@ -384,7 +386,7 @@ def resume_with_extension(self) -> None: } ) - self.ctrl_routine(False) # unpause + self.ctrl_routine(False) # unpause def close(self) -> None: logger.info("Closing routine subprocess and stopping timer.") diff --git a/src/badger/gui/components/run_monitor.py b/src/badger/gui/components/run_monitor.py index 3434c5d7..e6c09c0d 100644 --- a/src/badger/gui/components/run_monitor.py +++ b/src/badger/gui/components/run_monitor.py @@ -72,10 +72,12 @@ class BadgerOptMonitor(QWidget): sig_toggle_run = pyqtSignal(bool) sig_toggle_other = pyqtSignal(bool) sig_env_ready = pyqtSignal() - sig_env_reset = pyqtSignal() # notify on reset complete - sig_vars_set = pyqtSignal() # notify var set complete + sig_env_reset = pyqtSignal() # notify on reset complete + sig_vars_set = pyqtSignal() # notify var set complete - sig_paused = pyqtSignal(bool) # Notify home_page that run has been paused to make sure GUI updates + sig_paused = pyqtSignal( + bool + ) # Notify home_page that run has been paused to make sure GUI updates sig_termination_reached = pyqtSignal(dict) # run paused by a run-until condition def __init__(self, process_manager: "Optional[ProcessManager]" = None): @@ -446,6 +448,7 @@ def _configure_plot(self, plot_object, inspector, names: list[str]) -> dict: def init_routine_runner(self) -> None: self.reset_routine_runner() + # init routine runner self.routine_runner = routine_runner = BadgerRoutineSubprocess( self.process_manager, self.routine, @@ -462,8 +465,8 @@ def init_routine_runner(self) -> None: routine_runner.signals.states.connect(self.states) routine_runner.signals.sig_status.connect(self.sig_status.emit) routine_runner.signals.sig_termination_reached.connect( - lambda: self.set_paused(True) - ) + lambda: self.set_paused(True) + ) routine_runner.signals.sig_termination_reached.connect( self.sig_termination_reached.emit ) @@ -501,7 +504,7 @@ def start( def save_termination_condition(self, tc) -> None: self.termination_condition = tc - if self.routine_runner: # will be None on startup + if self.routine_runner: # will be None on startup self.routine_runner.set_termination_condition(tc) def enable_auto_range(self) -> None: @@ -690,7 +693,6 @@ 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) @@ -728,7 +730,8 @@ def routine_finished(self) -> None: for action in self.post_run_actions: action() - # self.reset_routine_runner() + # emit sig_routine_finished after archiving run data + self.sig_routine_finished.emit() def destroy_unused_env(self) -> None: if not self.running: @@ -788,7 +791,7 @@ def ctrl_routine(self, status) -> None: if not status: self.set_paused(False) self.sig_pause.emit(status) - + def resume_with_extension(self) -> None: """ Resume the active routine and extend the configured termination condition. @@ -885,6 +888,7 @@ def reset_env(self) -> None: self.sig_status.emit( f"Reset environment: Env vars {curr_vars} -> {self.init_vars}" ) + self.sig_env_reset.emit() # notify reset complete # QMessageBox.information(self, 'Reset Environment', # f'Env vars {curr_vars} -> {self.init_vars}') @@ -1052,6 +1056,7 @@ def set_vars(self) -> None: self.sig_status.emit( f"Dial in solution: {[f'{variable_names[i]}: {round(curr_vars[i], 4)} -> {round(updated_vars[i], 4)}' for i in range(len(variable_names))]}" ) + self.sig_vars_set.emit() # QMessageBox.information( # self, 'Set Environment', f'Env vars have been set to {solution}') diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index 4fccf9e8..292c5451 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -265,18 +265,26 @@ def config_logic(self): self.run_monitor.sig_toggle_other.connect(self.run_action_bar.toggle_other) self.run_monitor.sig_env_ready.connect(self.run_action_bar.env_ready) self.run_monitor.sig_env_ready.connect(self.update_saved_values_from_monitor) + # The following signal notifies that run_monitor has paused + self.run_monitor.sig_paused.connect(self.reflect_pause_state) + # Connect var set complete signals from monitor to run_controller restart_override_flag + self.run_monitor.sig_env_reset.connect( + self.run_controller.set_restart_override_flag + ) + self.run_monitor.sig_vars_set.connect( + self.run_controller.set_restart_override_flag + ) 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_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) - self.run_action_bar.sig_reset_env.connect(self.run_monitor.reset_env) self.run_action_bar.sig_reset_env.connect( - self.routine_editor.env_box.var_table.refresh_current_values - ) + self.run_monitor.reset_env + ) # on complete, emits sig_env_reset self.run_action_bar.sig_reset_env.connect( - self.run_controller.set_restart_override_flag + self.routine_editor.env_box.var_table.refresh_current_values ) self.run_action_bar.sig_save_checkpoint.connect( self.run_monitor.save_checkpoint @@ -290,11 +298,13 @@ def config_logic(self): self.run_action_bar.sig_jump_to_optimal.connect( self.run_monitor.jump_to_optimal ) - self.run_action_bar.sig_dial_in.connect(self.run_monitor.set_vars) + self.run_action_bar.sig_dial_in.connect( + self.run_monitor.set_vars + ) # on complete, emits sig_vars_set 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_action_bar.sig_flag_restart.connect( self.run_controller.set_restart_override_flag ) self.run_action_bar.sig_smart_run_ctrl.connect(self.smart_run_with_data) @@ -561,16 +571,14 @@ def edit_termination_condition(self): dlg.exec() finally: self.tc_dialog = None - self.run_action_bar.update_run_tooltip( - self.run_monitor.termination_condition - ) + self.run_action_bar.update_run_tooltip(self.run_monitor.termination_condition) def termination_updated(self, tc: dict): self.run_action_bar.update_run_tooltip(tc) def prepare_run(self, data=None, init_points_flag=True): """ - Prepares the run by composing the routine, validating data if present, + Prepares the run by composing the routine, validating data if present, saving created routine to a yaml file, and passing the routine to the run monitor to initialize plots. From 96f7400d3e3a5e1ca72fd9cfd9d6f2aeea073dd3 Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 29 Sep 2026 17:18:05 -0700 Subject: [PATCH 21/30] reflect termination condition in status while running --- src/badger/gui/components/routine_runner.py | 6 ++++++ src/badger/gui/components/run_monitor.py | 15 +++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/badger/gui/components/routine_runner.py b/src/badger/gui/components/routine_runner.py index 3619f9d2..c652d29c 100644 --- a/src/badger/gui/components/routine_runner.py +++ b/src/badger/gui/components/routine_runner.py @@ -94,6 +94,9 @@ def __init__( ) self.active_tc = None self.start_time = None # track the time cost of the run + self.last_resume_time = ( + None # track when the run was last (re)started for display + ) self.last_dump_time = None # track the time the run data got dumped self.data_and_error_queue = None self.stop_event = None @@ -125,6 +128,7 @@ def run(self, run_data_flag: bool = False, init_points_flag: bool = False) -> No logger.info("Starting routine run.") self.start_time = time.time() + self.last_resume_time = self.start_time self.last_dump_time = None # reset the timer # Patch for converting dtype str to torch object @@ -372,7 +376,9 @@ def ctrl_routine(self, pause: bool) -> None: """ if pause: self.pause_event.clear() + # Subprocess will pause at the start of its next iteration when it checks pause_event else: + self.last_resume_time = time.time() # record time of resume for display self.signals.sig_status.emit(f"Running routine {self.routine.name}...") self.pause_event.set() diff --git a/src/badger/gui/components/run_monitor.py b/src/badger/gui/components/run_monitor.py index e6c09c0d..e46016ab 100644 --- a/src/badger/gui/components/run_monitor.py +++ b/src/badger/gui/components/run_monitor.py @@ -9,6 +9,7 @@ import logging import os +import time import traceback from importlib import resources from typing import TYPE_CHECKING, List, Optional @@ -569,14 +570,24 @@ def update_status_with_tc(self): 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) + # Display total count, including previous data (match run monitor) + add_count = sum(1 for live_val in data["live"] if live_val == 0) + count = len(data) + max_eval += add_count else: count = len(data) if not self.paused: self.sig_status.emit( f"Running routine {self.routine.name}... [{count}/{max_eval}]" ) + elif idx == 1: + # display time remaining after last resume + max_time = self.termination_condition["max_time"] + elapsed = time.time() - self.routine_runner.last_resume_time + if not self.paused: + self.sig_status.emit( + f"Running routine {self.routine.name}... [{elapsed:.1f}/{max_time:.1f} s]" + ) else: self.sig_status.emit(f"Running routine {self.routine.name}...") From f907682aa5ca164cd283a39d74df718e37d37bf2 Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 29 Sep 2026 17:20:22 -0700 Subject: [PATCH 22/30] update ind_lim_vrange_dialog to show current bounds in preview when opened --- .../gui/windows/ind_lim_vrange_dialog.py | 69 ++++++++++++------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/src/badger/gui/windows/ind_lim_vrange_dialog.py b/src/badger/gui/windows/ind_lim_vrange_dialog.py index d0b591b1..2248c985 100644 --- a/src/badger/gui/windows/ind_lim_vrange_dialog.py +++ b/src/badger/gui/windows/ind_lim_vrange_dialog.py @@ -227,7 +227,7 @@ def init_ui(self): vbox_config.addWidget(stacks) # Bounds preview group - group_preview = QGroupBox("Bounds preview") + self.group_preview = group_preview = QGroupBox("Bounds Preview (Current)") vbox_preview = QVBoxLayout(group_preview) vbox_preview.setContentsMargins(8, 8, 8, 8) vbox_preview.setSpacing(5) @@ -280,40 +280,51 @@ def config_logic(self): self.sb_delta.valueChanged.connect(self.delta_changed) self.sb_bounds_lower.valueChanged.connect(self.bounds_lower_changed) self.sb_bounds_upper.valueChanged.connect(self.bounds_upper_changed) - self.update_bounds_preview() + self.bounds_changed = False + # Show the actual current bounds on open, before any recomputation. + self.update_bounds_preview(use_current_bounds=True) def _clip(self, value: float, lower: float, upper: float) -> float: """Clip value if outside of lower/upper bounds""" return max(lower, min(upper, value)) - def update_bounds_preview(self) -> None: + def update_bounds_preview(self, use_current_bounds: bool = False) -> None: """ - Calculate bounds preview and display on labels and preview bar + Calculate bounds preview and display on labels and preview bar. + + If ``use_current_bounds`` is True, the preview shows the actual + currently configured bounds instead of recomputing them from the + selected option/parameters, so the dialog opens showing what is + really applied rather than a value derived from possibly stale + ratio/delta settings. """ curr = float(self.configs.get("current_value", 0.0)) hard_lower = self.configs.get("lower_bound", 0) hard_upper = self.configs.get("upper_bound", 0) - option_idx = self.cb.currentIndex() - if option_idx == 0: - ratio = self.sb_ratio_curr.value() - sign = math.copysign(1.0, curr) if curr != 0 else 0.0 - bounds = [ - curr * (1 - 0.5 * sign * ratio), - curr * (1 + 0.5 * sign * ratio), - ] - elif option_idx == 1: - ratio = self.sb_ratio_full.value() - delta = 0.5 * ratio * (hard_upper - hard_lower) - bounds = [curr - delta, curr + delta] - elif option_idx == 2: - delta = self.sb_delta.value() - bounds = [curr - delta, curr + delta] + if use_current_bounds and "current_bounds" in self.configs: + bounds = list(self.configs["current_bounds"]) else: - bounds = [ - float(self.sb_bounds_lower.value()), - float(self.sb_bounds_upper.value()), - ] + option_idx = self.cb.currentIndex() + if option_idx == 0: + ratio = self.sb_ratio_curr.value() + sign = math.copysign(1.0, curr) if curr != 0 else 0.0 + bounds = [ + curr * (1 - 0.5 * sign * ratio), + curr * (1 + 0.5 * sign * ratio), + ] + elif option_idx == 1: + ratio = self.sb_ratio_full.value() + delta = 0.5 * ratio * (hard_upper - hard_lower) + bounds = [curr - delta, curr + delta] + elif option_idx == 2: + delta = self.sb_delta.value() + bounds = [curr - delta, curr + delta] + else: + bounds = [ + float(self.sb_bounds_lower.value()), + float(self.sb_bounds_upper.value()), + ] bounds = [ self._clip(bounds[0], hard_lower, hard_upper), @@ -321,6 +332,9 @@ def update_bounds_preview(self) -> None: ] bounds.sort() + self.group_preview.setTitle( + "Bounds Preview" if self.bounds_changed else "Bounds Preview (Current)" + ) self.lbl_preview_lower.setText(f"{bounds[0]:.6f}") self.lbl_preview_upper.setText(f"{bounds[1]:.6f}") self.bounds_preview_bar.set_values( @@ -350,31 +364,40 @@ def update_config(self): pass # Optionally handle invalid input def ratio_curr_changed(self, ratio_curr): + self.bounds_changed = True self.configs["ratio_curr"] = ratio_curr self.update_bounds_preview() def ratio_full_changed(self, ratio_full): + self.bounds_changed = True self.configs["ratio_full"] = ratio_full self.update_bounds_preview() def delta_changed(self, delta): + self.bounds_changed = True self.configs["delta"] = delta self.update_bounds_preview() def bounds_lower_changed(self, lower): + self.bounds_changed = True self.configs["exact_bounds"][0] = lower self.update_bounds_preview() def bounds_upper_changed(self, upper): + self.bounds_changed = True self.configs["exact_bounds"][1] = upper self.update_bounds_preview() def set(self): + if not self.bounds_changed: + self.close() + return self.update_config() self.apply_config(self.name, self.configs) self.close() def limit_option_changed(self, i): + self.bounds_changed = True self.stacks.setCurrentIndex(i) # Update configs From a021a402557b75488dfb71e35b34c916e6099e09 Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 29 Sep 2026 17:25:10 -0700 Subject: [PATCH 23/30] fix initial points bugs in gui --- src/badger/gui/components/routine_page.py | 3 ++- src/badger/gui/mini/components/var_table.py | 3 ++- src/badger/gui/mini/pages/home_page.py | 1 + src/badger/gui/mini/pages/routine_page.py | 3 ++- src/badger/gui/pages/home_page.py | 4 ++++ 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/badger/gui/components/routine_page.py b/src/badger/gui/components/routine_page.py index 7f5610bf..84c077dc 100644 --- a/src/badger/gui/components/routine_page.py +++ b/src/badger/gui/components/routine_page.py @@ -1295,7 +1295,8 @@ def fill_curr_in_init_table(self, record=False): ): # Fill the row with content_list for col, name in enumerate(vname_selected): - item = QTableWidgetItem(f"{var_curr[name]:.6g}") + # Rounding the value here can leave it outside the variable bounds + item = QTableWidgetItem(f"{var_curr[name]}") table.setItem(row, col, item) break # Stop after filling the first non-empty row diff --git a/src/badger/gui/mini/components/var_table.py b/src/badger/gui/mini/components/var_table.py index f4cc9a23..d2875d30 100644 --- a/src/badger/gui/mini/components/var_table.py +++ b/src/badger/gui/mini/components/var_table.py @@ -693,7 +693,8 @@ def set_saved_values(self, values_by_name: dict[str, float]) -> None: self.saved_values[name] = float(value) if self.variables: - self.update_variables(variables=self.variables, filtered=2) + # update table, filtered=3 stops signals from recalculating initial points + self.update_variables(variables=self.variables, filtered=3) def _set_cell_value_style(self, row: int, is_selected: bool): """Update Saved/Current/Scan Range style for a row based on selection.""" diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index 292c5451..f871c1ef 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -661,6 +661,7 @@ def start_run(self, load_displayed_data: bool = False): if run_data_flag: init_points_flag = False + self.routine_editor.update_init_table() # update initial points before prepare run if run_data_flag: data_to_load = self.load_data_from_run() self.prepare_run( diff --git a/src/badger/gui/mini/pages/routine_page.py b/src/badger/gui/mini/pages/routine_page.py index 717bdf88..948ed9fc 100644 --- a/src/badger/gui/mini/pages/routine_page.py +++ b/src/badger/gui/mini/pages/routine_page.py @@ -1252,7 +1252,8 @@ def fill_curr_in_init_table(self, record=False): ): # Fill the row with content_list for col, name in enumerate(vname_selected): - item = QTableWidgetItem(f"{var_curr[name]:.6g}") + # Rounding the value here can leave it outside the variable bounds + item = QTableWidgetItem(f"{var_curr[name]}") table.setItem(row, col, item) break # Stop after filling the first non-empty row diff --git a/src/badger/gui/pages/home_page.py b/src/badger/gui/pages/home_page.py index 136a47a1..22217c68 100644 --- a/src/badger/gui/pages/home_page.py +++ b/src/badger/gui/pages/home_page.py @@ -562,6 +562,10 @@ def start_run(self, load_displayed_data: bool = False) -> None: run_data_flag = load_displayed_data or self.data_panel.use_data init_points_flag = self.data_panel.init_points + if self.routine_editor.env_box.relative_to_curr.isChecked(): + # update initial points before initializing routine + self.routine_editor.update_init_table() + if run_data_flag: data_to_load = self.data_panel.get_data() # Get data from data_panel self.prepare_run( From c5c575e59d7fc7f61ad550f299074eef1d97098e Mon Sep 17 00:00:00 2001 From: Mitchell Victoriano Date: Thu, 1 Oct 2026 19:22:37 -0700 Subject: [PATCH 24/30] Adds new key to assertion with new arg in subprocess --- src/badger/tests/test_create_process.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/badger/tests/test_create_process.py b/src/badger/tests/test_create_process.py index 02fcfddf..d8846156 100644 --- a/src/badger/tests/test_create_process.py +++ b/src/badger/tests/test_create_process.py @@ -45,6 +45,7 @@ def test_create_subprocess_emits_signals(qtbot, process_creator): "evaluate_queue", "wait_event", "dialog_action_queue", + "termination_control_queue", } assert isinstance(emitted_args["args_queue"], mp.queues.Queue) From b373214650daa59c10a7f6e13cee0904b10c2cb7 Mon Sep 17 00:00:00 2001 From: michaellans Date: Fri, 2 Oct 2026 08:26:31 -0700 Subject: [PATCH 25/30] fix test_run_monitor tests --- src/badger/tests/test_run_monitor.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/badger/tests/test_run_monitor.py b/src/badger/tests/test_run_monitor.py index 6537376f..82e0f9e8 100644 --- a/src/badger/tests/test_run_monitor.py +++ b/src/badger/tests/test_run_monitor.py @@ -47,7 +47,11 @@ def monitor(self, process_manager, init_multiprocessing): monitor.testing = True monitor.routine = routine - return monitor + yield monitor + + runner = monitor.routine_runner + if runner is not None and runner.routine_process is not None: + runner.stop_routine() @pytest.fixture def home_page(self, process_manager): @@ -247,10 +251,10 @@ def test_y_axis_specification(self, qtbot, monitor): "tc_idx": 0, "max_eval": 10, } - monitor.start(True) + monitor.start() # Wait until the run is done - while monitor.running: + while not monitor.paused: qtbot.wait(100) select_x_plot_y_axis_spy = QSignalSpy(monitor.cb_plot_y.currentIndexChanged) @@ -293,10 +297,11 @@ def test_pause_play(self, qtbot, home_page): 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) + paused_spy = QSignalSpy(monitor.sig_paused) + monitor.start() monitor.ctrl_routine(True) + qtbot.waitUntil(lambda: len(paused_spy) > 0, timeout=500) # 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 @@ -310,8 +315,7 @@ def test_pause_play(self, qtbot, home_page): assert len(spy) == 2 assert spy[1][0] is False - while monitor.running: - qtbot.wait(100) + monitor.routine_runner.stop_routine() def test_jump_to_optimum(self, qtbot, home_page): monitor = home_page.run_monitor From a4f738bb1db9d4ebc1e977a8626b633f0d832730 Mon Sep 17 00:00:00 2001 From: michaellans Date: Fri, 2 Oct 2026 09:33:58 -0700 Subject: [PATCH 26/30] emit status message when pausing --- src/badger/gui/components/run_monitor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/badger/gui/components/run_monitor.py b/src/badger/gui/components/run_monitor.py index 2403ac53..6145dada 100644 --- a/src/badger/gui/components/run_monitor.py +++ b/src/badger/gui/components/run_monitor.py @@ -795,7 +795,7 @@ def set_paused(self, paused: bool) -> None: self.sig_status.emit(f"Routine {self.routine.name} paused") self.sig_paused.emit(paused) - def ctrl_routine(self, status) -> None: + def ctrl_routine(self, status: bool) -> None: """Called from home_page to pause(True)/unpause(False) subprocess loop. On pause, the GUI is not reflected as paused until the subprocess confirms @@ -803,6 +803,8 @@ def ctrl_routine(self, status) -> None: """ if not status: self.set_paused(False) + else: + self.sig_status.emit("pausing routine...") self.sig_pause.emit(status) def resume_with_extension(self) -> None: From d6ac0c2fb35a36d74445ebac12708bbec78d6d34 Mon Sep 17 00:00:00 2001 From: michaellans Date: Fri, 2 Oct 2026 11:27:19 -0700 Subject: [PATCH 27/30] type hints and formatting --- src/badger/core_subprocess.py | 14 ++-- src/badger/gui/components/action_bar.py | 78 ++++++++++--------- src/badger/gui/components/run_monitor.py | 7 +- .../gui/mini/components/run_controller.py | 18 ++--- src/badger/gui/mini/pages/home_page.py | 18 +++-- src/badger/gui/mini/pages/routine_page.py | 2 +- src/badger/gui/pages/home_page.py | 2 +- .../windows/terminition_condition_dialog.py | 2 - 8 files changed, 71 insertions(+), 70 deletions(-) diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index 13e367fd..b29c86b5 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -173,7 +173,7 @@ def check_termination_condition( def extend_termination_from_current( updated_condition: dict, routine: Routine, start_time: float -): +) -> dict: """Extend a termination condition by updated_condition from the current routine state. Parameters @@ -204,12 +204,12 @@ def extend_termination_from_current( def check_for_extension( - termination_condition, - queue, - termination_control_queue, - routine, - start_time, -): + termination_condition: dict, + queue: mp.Queue, + termination_control_queue: mp.Queue, + routine: Routine, + start_time: float, +) -> dict | None: """ Checks termination_control_queue for an updated termination condition from the GUI. This is expected to be called after the subprocess resumes following a pause, so that diff --git a/src/badger/gui/components/action_bar.py b/src/badger/gui/components/action_bar.py index 7ce60efb..61bf4be4 100644 --- a/src/badger/gui/components/action_bar.py +++ b/src/badger/gui/components/action_bar.py @@ -3,8 +3,8 @@ from importlib import resources -from PyQt5.QtCore import QEvent, QSize, Qt, pyqtSignal -from PyQt5.QtGui import QFont, QIcon, QPainter, QPalette +from PyQt5.QtCore import QEvent, QPoint, QSize, Qt, pyqtSignal +from PyQt5.QtGui import QFont, QIcon, QPainter, QPaintEvent, QPalette from PyQt5.QtWidgets import ( QAction, QHBoxLayout, @@ -25,7 +25,7 @@ class SplitTooltipToolButton(QToolButton): Use arg menu_tooltip="desired tooltip" to set the menu tooltip """ - def __init__(self, menu_tooltip="", parent=None): + def __init__(self, menu_tooltip: str = "", parent=None): """ Parameters ---------- @@ -34,36 +34,38 @@ def __init__(self, menu_tooltip="", parent=None): """ super().__init__(parent) self.menu_tooltip = menu_tooltip - self.display_text = None # show next termination condition - self._last_display_text = None # used to show/hide text while running + self.display_text: str | None = None # show next termination condition + self._last_display_text: str | None = ( + None # used to show/hide text while running + ) font = QFont() font.setWeight(QFont.Normal) font.setPixelSize(10) self.setFont(font) - def setDisplayText(self, text): + def setDisplayText(self, text: str) -> None: self.display_text = text self.update() - def setDefaultAction(self, action): + def setDefaultAction(self, action: QAction) -> None: super().setDefaultAction(action) self.setIcon(action.icon()) self.update() - def hide_text(self): + def hide_text(self) -> None: self._last_display_text = self.display_text self.setDisplayText("") - def show_text(self): + def show_text(self) -> None: self.setDisplayText(self._last_display_text) - def initStyleOption(self, option): + def initStyleOption(self, option) -> None: super().initStyleOption(option) if self.display_text is not None: option.text = self.display_text - def paintEvent(self, event): + def paintEvent(self, event: QPaintEvent) -> None: option = QStyleOptionToolButton() self.initStyleOption(option) painter = QPainter(self) @@ -77,7 +79,7 @@ def paintEvent(self, event): text_rect, Qt.AlignLeft | Qt.AlignVCenter, self.display_text or "" ) - def _over_menu_arrow(self, pos): + def _over_menu_arrow(self, pos: QPoint) -> bool: opt = QStyleOptionToolButton() self.initStyleOption(opt) rect = self.style().subControlRect( @@ -85,7 +87,7 @@ def _over_menu_arrow(self, pos): ) return rect.contains(pos) - def event(self, event): + def event(self, event: QEvent) -> bool: if event.type() == QEvent.ToolTip and self._over_menu_arrow(event.pos()): from PyQt5.QtWidgets import QToolTip @@ -195,14 +197,14 @@ class BadgerActionBar(QWidget): sig_edit_checkpoint = pyqtSignal() sig_load_checkpoint = pyqtSignal() - def __init__(self, parent=None, minimode: bool = False): + def __init__(self, parent: QWidget | None = None, minimode: bool = False) -> None: super().__init__(parent) self.mini_mode = minimode self.docs_name = "gui-usage" self.init_ui() self.config_logic() - def init_ui(self): + def init_ui(self) -> None: def load_internal_icon(name: str) -> QIcon: icon_ref = resources.files(__package__) / f"../images/{name}" with resources.as_file(icon_ref) as icon_path: @@ -357,7 +359,7 @@ def load_internal_icon(name: str) -> QIcon: } """) - def config_logic(self): + def config_logic(self) -> None: self.btn_del.clicked.connect(self.delete_run) self.btn_log.clicked.connect(self.logbook) self.btn_help.clicked.connect(self.open_docs) @@ -382,7 +384,7 @@ def config_logic(self): ) self.btn_open_extensions_palette.clicked.connect(self.open_extensions_palette) - def lock(self): + def lock(self) -> None: self.btn_del.setDisabled(True) self.btn_log.setDisabled(True) self.btn_reset.setDisabled(True) @@ -391,7 +393,7 @@ def lock(self): self.btn_opt.setDisabled(True) self.btn_set.setDisabled(True) - def unlock(self): + def unlock(self) -> None: self.btn_del.setDisabled(False) self.btn_log.setDisabled(False) self.btn_reset.setDisabled(False) @@ -400,10 +402,10 @@ def unlock(self): self.btn_opt.setDisabled(False) self.btn_set.setDisabled(False) - def routine_invalid(self): + def routine_invalid(self) -> None: self.btn_stop.setDisabled(False) - def routine_finished(self): + def routine_finished(self) -> None: # Note the order of the following two lines cannot be changed! self.btn_stop.setPopupMode(QToolButton.MenuButtonPopup) self.btn_stop.setStyleSheet(stylesheet_run) @@ -421,19 +423,19 @@ def routine_finished(self): self.btn_set.setDisabled(False) self.btn_del.setDisabled(False) - def toggle_reset(self, locked): + def toggle_reset(self, locked: bool) -> None: self.btn_reset.setDisabled(locked) - def toggle_run(self, locked): + def toggle_run(self, locked: bool) -> None: self.btn_stop.setDisabled(locked) - def toggle_other(self, locked): + def toggle_other(self, locked: bool) -> None: self.btn_del.setDisabled(locked) self.btn_log.setDisabled(locked) self.btn_opt.setDisabled(locked) self.btn_set.setDisabled(locked) - def run_start(self): + def run_start(self) -> None: self.btn_stop.setStyleSheet(stylesheet_stop) # self.btn_stop.setPopupMode(QToolButton.DelayedPopup) self.btn_stop.setDisabled(False) @@ -449,7 +451,7 @@ def run_start(self): self.btn_set.setDisabled(True) self.update_stop_menu(False) - def set_run_action(self): + def set_run_action(self) -> None: if self.mini_mode: # run action "New Run" sets flag to restart in run_controller self.sig_flag_restart.emit() @@ -474,7 +476,7 @@ def set_smart_run_action(self): self.sig_smart_run_ctrl.emit() - def _on_run_action_triggered(self): + def _on_run_action_triggered(self) -> None: self.set_run_action() def _on_run_until_action_triggered(self): @@ -483,29 +485,29 @@ 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): + def _on_smart_run_action_triggered(self) -> None: self.set_smart_run_action() - def delete_run(self): + def delete_run(self) -> None: self.sig_delete_run.emit() - def logbook(self): + def logbook(self) -> None: self.sig_logbook.emit() - def open_docs(self): + def open_docs(self) -> None: self.window_docs.update_docs(self.docs_name) self.window_docs.show() - def reset_env(self): + def reset_env(self) -> None: self.sig_reset_env.emit() - def jump_to_optimal(self): + def jump_to_optimal(self) -> None: self.sig_jump_to_optimal.emit() - def dial_in(self): + def dial_in(self) -> None: self.sig_dial_in.emit() - def handle_pause_action(self, status: bool): + def handle_pause_action(self, status: bool) -> None: """ Enable/disable buttons for pause (true)/resume (false) optimization """ @@ -530,7 +532,7 @@ def handle_pause_action(self, status: bool): self.update_stop_menu(status) - def update_stop_menu(self, status: bool): + def update_stop_menu(self, status: bool) -> None: """Update run menu options when routine is paused (true)/running (false)""" if status: self.run_menu.clear() @@ -551,14 +553,14 @@ def update_stop_menu(self, status: bool): self.run_menu.addAction(self.smart_run_action) self.btn_stop.hide_text() - def open_extensions_palette(self): + def open_extensions_palette(self) -> None: self.sig_open_extensions_palette.emit() - def env_ready(self): + def env_ready(self) -> None: self.btn_log.setDisabled(False) self.btn_opt.setDisabled(False) - def update_run_tooltip(self, tc: dict | None = None): + def update_run_tooltip(self, tc: dict[str, int | float] | None = None) -> None: """Update btn_stop tooltip with next termination condition""" tc_text = "" if tc is None: diff --git a/src/badger/gui/components/run_monitor.py b/src/badger/gui/components/run_monitor.py index 6145dada..00fccae9 100644 --- a/src/badger/gui/components/run_monitor.py +++ b/src/badger/gui/components/run_monitor.py @@ -76,9 +76,8 @@ class BadgerOptMonitor(QWidget): sig_env_reset = pyqtSignal() # notify on reset complete sig_vars_set = pyqtSignal() # notify var set complete - sig_paused = pyqtSignal( - bool - ) # Notify home_page that run has been paused to make sure GUI updates + # Notify home_page that run has been paused to make sure GUI updates + sig_paused = pyqtSignal(bool) # paused (true)/ unpaused (false) sig_termination_reached = pyqtSignal(dict) # run paused by a run-until condition def __init__(self, process_manager: "ProcessManager | None" = None): @@ -560,7 +559,7 @@ def update(self, results: pd.DataFrame) -> None: # Check critical condition self.check_critical() - def update_status_with_tc(self): + def update_status_with_tc(self) -> None: if self.paused: return diff --git a/src/badger/gui/mini/components/run_controller.py b/src/badger/gui/mini/components/run_controller.py index 5eea978b..65603a55 100644 --- a/src/badger/gui/mini/components/run_controller.py +++ b/src/badger/gui/mini/components/run_controller.py @@ -14,20 +14,20 @@ class SmartRunController(QObject): 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.last_routine_dict: dict[str, object] | None = None + self._pending_start: bool = False + self._load_data: bool = False + self._new_routine_dict: dict[str, object] | None = None self.restart_override_flag: bool = False def smart_run( self, - routine_params_dict: dict, + routine_params_dict: dict[str, object], is_running: bool, is_paused: bool, data_compatible: bool, - ): + ) -> None: """ Determine whether to pause, resume, continue, or restart a run. @@ -98,7 +98,7 @@ def smart_run( # start fresh run self.start_run(False) - def set_restart_override_flag(self): + def set_restart_override_flag(self) -> None: """ 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 @@ -108,14 +108,14 @@ def set_restart_override_flag(self): """ self.restart_override_flag = True - def notify_routine_finished(self): + def notify_routine_finished(self) -> None: """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): + def start_run(self, load_displayed_data: bool) -> None: """Emit the signal to start a run with or without displayed data. Parameters diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index 1c625d69..be430fcc 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -23,6 +23,7 @@ QVBoxLayout, QWidget, ) +from xopt.vocs import VOCS from badger.archive import ( delete_run, @@ -469,7 +470,7 @@ def toggle_lock(self, lock, lock_tab=1): self.uncover_page() - def loaded_data_keys_compatible(self, vocs) -> bool: + def loaded_data_keys_compatible(self, vocs: VOCS) -> bool: """True if the displayed routine has data whose variable/objective keys match vocs.""" routine = self.current_routine @@ -483,7 +484,7 @@ def loaded_data_keys_compatible(self, vocs) -> bool: vocs.variable_names + vocs.objective_names ) - def validate_loaded_data_keys(self, vocs, open_dialog: bool = True): + def validate_loaded_data_keys(self, vocs: VOCS, open_dialog: bool = True) -> None: """ 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 @@ -531,7 +532,7 @@ 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): + def smart_run_with_data(self) -> None: # get current routine_page parameters routine_editor_snapshot = self.routine_editor.get_routine_snapshot() vocs = self.routine_editor.env_box.compose_vocs()[0] @@ -544,19 +545,20 @@ def smart_run_with_data(self): data_compatible=data_compatible, ) - def handle_pause(self, pause: bool): + def handle_pause(self, pause: bool) -> None: if pause: # pause routine self.run_monitor.ctrl_routine(True) else: self.run_monitor.resume_with_extension() - def reflect_pause_state(self, paused: bool): + def reflect_pause_state(self, paused: bool) -> None: """Sync the GUI to the run pause state, whoever initiated it.""" self.run_action_bar.handle_pause_action(paused) self.toggle_lock(not paused) - def edit_termination_condition(self): + def edit_termination_condition(self) -> None: + """Opens dialog to update termination condition""" dlg = BadgerTerminationConditionDialog( self, run_opt=None, @@ -570,7 +572,7 @@ def edit_termination_condition(self): self.tc_dialog = None self.run_action_bar.update_run_tooltip(self.run_monitor.termination_condition) - def termination_updated(self, tc: dict): + def termination_updated(self, tc: dict) -> None: self.run_action_bar.update_run_tooltip(tc) def prepare_run(self, data=None, init_points_flag=True): @@ -633,7 +635,7 @@ 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, load_displayed_data: bool = False): + def start_run(self, load_displayed_data: bool = False) -> None: """ Prepares and starts optimization run with provided options. - Termination Condition is provided when called via BadgerTerminationConditionDialog diff --git a/src/badger/gui/mini/pages/routine_page.py b/src/badger/gui/mini/pages/routine_page.py index ed1895b0..5b42a3a8 100644 --- a/src/badger/gui/mini/pages/routine_page.py +++ b/src/badger/gui/mini/pages/routine_page.py @@ -302,7 +302,7 @@ def set_saved_values_from_init_vars( } self.env_box.var_table.set_saved_values(values_by_name) - def get_routine_snapshot(self): + def get_routine_snapshot(self) -> dict: routine_dict = self.generate_template_dict_from_gui() return routine_dict diff --git a/src/badger/gui/pages/home_page.py b/src/badger/gui/pages/home_page.py index ac344068..dad2b53b 100644 --- a/src/badger/gui/pages/home_page.py +++ b/src/badger/gui/pages/home_page.py @@ -588,7 +588,7 @@ def start_run(self, load_displayed_data: bool = False) -> None: init_points_flag=init_points_flag, ) - def termination_reached(self, tc_condition: dict): + def termination_reached(self, tc_condition: dict) -> None: """ The run has been paused from the subprocess by reaching a termination condition. Opens a dialog to resume or stop. diff --git a/src/badger/gui/windows/terminition_condition_dialog.py b/src/badger/gui/windows/terminition_condition_dialog.py index 47387b9c..d4401b72 100644 --- a/src/badger/gui/windows/terminition_condition_dialog.py +++ b/src/badger/gui/windows/terminition_condition_dialog.py @@ -204,6 +204,4 @@ def terminition_condition_changed(self, i): self.configs["tc_idx"] = i def closeEvent(self, event): - # self.save_config(self.configs) - event.accept() From d149aa8b89c908b5affcd6c9f76d2dcd388ceeab Mon Sep 17 00:00:00 2001 From: michaellans Date: Fri, 2 Oct 2026 11:28:24 -0700 Subject: [PATCH 28/30] remove run_until_action and connected signals --- src/badger/gui/components/action_bar.py | 23 +++-------------------- src/badger/gui/mini/pages/home_page.py | 1 - src/badger/gui/pages/home_page.py | 1 - 3 files changed, 3 insertions(+), 22 deletions(-) diff --git a/src/badger/gui/components/action_bar.py b/src/badger/gui/components/action_bar.py index 61bf4be4..b1b4b101 100644 --- a/src/badger/gui/components/action_bar.py +++ b/src/badger/gui/components/action_bar.py @@ -176,9 +176,6 @@ def event(self, event: QEvent) -> bool: class BadgerActionBar(QWidget): sig_start = pyqtSignal() - sig_start_until = pyqtSignal( # I think this is now deprecated - bool - ) # bool True launches termination condition dialog menu sig_stop = pyqtSignal() sig_flag_restart = pyqtSignal() sig_delete_run = pyqtSignal() @@ -290,8 +287,6 @@ def load_internal_icon(name: str) -> QIcon: # perform the associated actions, but do not update the default self.run_action = run_action = QAction("New Run", 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_time) self.run_until_menu_action = run_until_menu_action = QAction( "Edit Condition", self ) @@ -309,8 +304,6 @@ def load_internal_icon(name: str) -> QIcon: # menu will update the default run action. self.run_action = run_action = QAction("New Run", 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_time) self.run_until_menu_action = run_until_menu_action = QAction( "Run until", self ) @@ -367,7 +360,6 @@ def config_logic(self) -> None: self.btn_opt.clicked.connect(self.jump_to_optimal) self.btn_set.clicked.connect(self.dial_in) 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 ) @@ -441,8 +433,6 @@ def run_start(self) -> None: self.btn_stop.setDisabled(False) self.run_action.setText("Stop") 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.smart_run_action.setText("Pause") @@ -467,10 +457,7 @@ def set_run_action(self) -> None: self.btn_stop.setDisabled(True) self.sig_start.emit() - def set_run_until_action(self, from_menu=False): - self.sig_update_tc.emit() - - def set_smart_run_action(self): + def set_smart_run_action(self) -> None: if self.btn_stop.defaultAction() is not self.smart_run_action: self.btn_stop.setDefaultAction(self.smart_run_action) @@ -479,11 +466,8 @@ def set_smart_run_action(self): def _on_run_action_triggered(self) -> None: 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 _on_run_until_menu_action_triggered(self) -> None: + self.sig_update_tc.emit() def _on_smart_run_action_triggered(self) -> None: self.set_smart_run_action() @@ -574,7 +558,6 @@ def update_run_tooltip(self, tc: dict[str, int | float] | None = None) -> None: elif tc_idx == 1: tc_text = f"{int(tc.get('max_time', 0))} s" tip = f"Run until: timeout = {tc_text}" - self.run_until_action.setToolTip(tip) self.run_until_menu_action.setToolTip(tip) self.update_run_button_text(str(tc_text)) diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index be430fcc..0aacbfd0 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -274,7 +274,6 @@ 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_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) diff --git a/src/badger/gui/pages/home_page.py b/src/badger/gui/pages/home_page.py index dad2b53b..79d65790 100644 --- a/src/badger/gui/pages/home_page.py +++ b/src/badger/gui/pages/home_page.py @@ -266,7 +266,6 @@ def config_logic(self) -> None: self.run_monitor.sig_termination_reached.connect(self.termination_reached) 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(load_displayed_data=True) ) From d0cea9821beff40349af730edd5dd83af55c5eb3 Mon Sep 17 00:00:00 2001 From: michaellans Date: Wed, 7 Oct 2026 11:13:06 -0700 Subject: [PATCH 29/30] Add stop/pause checks to intial point loop and extract pause check to method --- src/badger/core_subprocess.py | 75 ++++++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index b29c86b5..5449de69 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -245,6 +245,43 @@ def check_for_extension( logger.warning("Ignoring unexpected resume message: %r", msg) +def check_for_pause_resume( + termination_condition: dict | None, + queue: mp.Queue, + termination_control_queue: mp.Queue, + routine: Routine, + start_time: float, + pause_process: mp.Event, + stop_process: mp.Event, +) -> dict | None: + """ + Wait for resume when paused, then check for a termination update. While paused, + checks stop_process and raises BadgerRunTerminated if set. + + Returns + ------- + dict: Updated termination_condition dictionary + """ + logger.info("Pause process not set. Waiting...") + queue.put({"type": PAUSE_ACK_TYPE}) + while not pause_process.wait(timeout=1.0): + # Can still be stopped while paused + if stop_process.is_set(): + raise BadgerRunTerminated + + if stop_process.is_set(): + raise BadgerRunTerminated + + termination_condition = check_for_extension( + termination_condition, + queue, + termination_control_queue, + routine, + start_time, + ) + return termination_condition + + 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. @@ -442,6 +479,22 @@ def run_routine_subprocess( if args["init_points"]: logger.info("Evaluating initial points...") for _, ele in initial_points.iterrows(): + # check for stop + if stop_process.is_set(): + logger.info("Stop process set. Terminating optimization.") + evaluate_queue[0].close() + raise BadgerRunTerminated + # check for pause + if not pause_process.is_set(): + termination_condition = check_for_pause_resume( + termination_condition, + queue, + termination_control_queue, + routine, + start_time, + pause_process, + stop_process, + ) logger.debug(f"Evaluating initial point: {ele.to_dict()}") result = evaluate_measurement_with_retry( routine, ele.to_dict(), queue, stop_process, dialog_action_queue @@ -473,18 +526,14 @@ def run_routine_subprocess( termination_condition = None if not pause_process.is_set(): - logger.info("Pause process not set. Waiting...") - queue.put({"type": PAUSE_ACK_TYPE}) # send pause acknowledgement to gui - pause_process.wait() - - # On resume, check for a new termination condition - # and clear or extend - termination_condition = check_for_extension( + termination_condition = check_for_pause_resume( termination_condition, queue, termination_control_queue, routine, start_time, + pause_process, + stop_process, ) continue @@ -497,11 +546,15 @@ def run_routine_subprocess( evaluate_queue[0].close() raise BadgerRunTerminated elif not pause_process.is_set(): - logger.info( - "Pause process not set during optimization loop. Waiting..." + termination_condition = check_for_pause_resume( + termination_condition, + queue, + termination_control_queue, + routine, + start_time, + pause_process, + stop_process, ) - queue.put({"type": PAUSE_ACK_TYPE}) # acknowledge pause - pause_process.wait() result = evaluate_measurement_with_retry( routine, candidates, queue, stop_process, dialog_action_queue From b287ad1baa4bc51517973ee164af31850fedb43c Mon Sep 17 00:00:00 2001 From: Mitchell Victoriano Date: Wed, 7 Oct 2026 16:49:10 -0700 Subject: [PATCH 30/30] fixed incorrect VOCS import --- src/badger/gui/components/data_panel.py | 2 +- src/badger/gui/components/env_cbox.py | 3 +-- src/badger/gui/components/generator_cbox.py | 2 +- src/badger/gui/components/pydantic_editor.py | 2 +- src/badger/gui/components/run_monitor.py | 3 ++- src/badger/gui/mini/pages/home_page.py | 2 +- src/badger/gui/windows/load_data_from_run_dialog.py | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/badger/gui/components/data_panel.py b/src/badger/gui/components/data_panel.py index e64a339b..4fb7b610 100644 --- a/src/badger/gui/components/data_panel.py +++ b/src/badger/gui/components/data_panel.py @@ -2,6 +2,7 @@ users load data from archived runs or clear the buffer before starting.""" import pandas as pd +from gest_api.vocs import VOCS from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QCheckBox, @@ -13,7 +14,6 @@ QVBoxLayout, QWidget, ) -from xopt.vocs import VOCS from badger.gui.components.data_table import ( TableWithCopy, diff --git a/src/badger/gui/components/env_cbox.py b/src/badger/gui/components/env_cbox.py index 8342d717..ee0b3536 100644 --- a/src/badger/gui/components/env_cbox.py +++ b/src/badger/gui/components/env_cbox.py @@ -22,7 +22,7 @@ from importlib import resources from typing import Any -from gest_api.vocs import ContinuousVariable +from gest_api.vocs import VOCS, ContinuousVariable from pydantic_core import ValidationError from PyQt5.QtCore import QPropertyAnimation, QRegExp, pyqtSignal from PyQt5.QtGui import QFont, QIcon @@ -37,7 +37,6 @@ QVBoxLayout, QWidget, ) -from xopt.vocs import VOCS from badger.errors import BadgerRoutineError from badger.gui.components.collapsible_box import CollapsibleBox diff --git a/src/badger/gui/components/generator_cbox.py b/src/badger/gui/components/generator_cbox.py index a4bf3629..0ea44c75 100644 --- a/src/badger/gui/components/generator_cbox.py +++ b/src/badger/gui/components/generator_cbox.py @@ -1,6 +1,7 @@ """Panel where users pick an optimization algorithm from the Xopt registry and configure its parameters via the Pydantic tree editor.""" +from gest_api.vocs import VOCS from PyQt5.QtWidgets import ( QCheckBox, QComboBox, @@ -12,7 +13,6 @@ QVBoxLayout, QWidget, ) -from xopt.vocs import VOCS from badger.gui.components.collapsible_box import CollapsibleBox from badger.gui.components.pydantic_editor import BadgerPydanticEditor diff --git a/src/badger/gui/components/pydantic_editor.py b/src/badger/gui/components/pydantic_editor.py index dd29ba52..14ac3dc9 100644 --- a/src/badger/gui/components/pydantic_editor.py +++ b/src/badger/gui/components/pydantic_editor.py @@ -29,6 +29,7 @@ import yaml from bax_algorithms.emittance import PathwiseMinimizeEmittance from bax_algorithms.solenoid_alignment import PathwiseSolenoidAlignment +from gest_api.vocs import VOCS from pydantic import BaseModel, Field, ValidationError, create_model from pydantic.fields import FieldInfo from pydantic_core import PydanticUndefined, PydanticUndefinedType @@ -57,7 +58,6 @@ from xopt.generators.bayesian.bayesian_generator import BayesianGenerator from xopt.generators.bayesian.turbo import TurboController from xopt.numerical_optimizer import NumericalOptimizer -from xopt.vocs import VOCS logger = logging.getLogger(__name__) diff --git a/src/badger/gui/components/run_monitor.py b/src/badger/gui/components/run_monitor.py index 00fccae9..f03d4433 100644 --- a/src/badger/gui/components/run_monitor.py +++ b/src/badger/gui/components/run_monitor.py @@ -17,6 +17,7 @@ import numpy as np import pandas as pd import pyqtgraph as pg +from gest_api.vocs import VOCS from PyQt5.QtCore import pyqtSignal from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import ( @@ -32,7 +33,7 @@ QWidget, ) from pyqtgraph.Qt import QtCore, QtGui -from xopt.vocs import VOCS, normalize_inputs, select_best +from xopt.vocs import normalize_inputs, select_best from badger.archive import BADGER_ARCHIVE_ROOT, archive_run from badger.gui.components.analysis_extensions import AnalysisExtension diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index 0aacbfd0..3fbcb8fe 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -13,6 +13,7 @@ from importlib import resources import numpy as np +from gest_api.vocs import VOCS from pandas import DataFrame from PyQt5.QtCore import QModelIndex, Qt, pyqtSignal from PyQt5.QtGui import QIcon @@ -23,7 +24,6 @@ QVBoxLayout, QWidget, ) -from xopt.vocs import VOCS from badger.archive import ( delete_run, diff --git a/src/badger/gui/windows/load_data_from_run_dialog.py b/src/badger/gui/windows/load_data_from_run_dialog.py index e9346c2a..869a717b 100644 --- a/src/badger/gui/windows/load_data_from_run_dialog.py +++ b/src/badger/gui/windows/load_data_from_run_dialog.py @@ -7,6 +7,7 @@ import numpy as np import pandas as pd import pyqtgraph as pg +from gest_api.vocs import VOCS from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QDialog, @@ -19,7 +20,6 @@ QWidget, ) from pyqtgraph.Qt import QtCore, QtGui -from xopt.vocs import VOCS from badger.archive import ( get_base_run_filename,