diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index c67d7d7a..5449de69 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -23,6 +23,7 @@ from pandas import DataFrame from xopt.errors import FeasibilityError, XoptError +from xopt.generators.sequential import SequentialGenerator from xopt.vocs import select_best from badger.errors import ( @@ -30,9 +31,7 @@ MEASUREMENT_ACTION_RETRY, MEASUREMENT_ACTION_TYPE, MEASUREMENT_ERROR_TYPE, - TERMINATION_ACTION_CONTINUE, - TERMINATION_ACTION_END, - TERMINATION_ACTION_TYPE, + PAUSE_ACK_TYPE, TERMINATION_REACHED_TYPE, BadgerEnvObsError, BadgerRunTerminated, @@ -95,45 +94,192 @@ def evaluate_measurement_with_retry( ) -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, - 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, - } - ) +) -> bool: + """ + Check whether termination conditon has been 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 + + 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.") + # clear pause_process. This pauses the optimizaiton loop at the + # next 'if not pause_process.is_set()' check) + pause_process.clear() + # 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: + 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.") + # clear pause_process. This pauses the optimizaiton loop at the + # next 'if not pause_process.is_set()' check) + pause_process.clear() + # 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 - while True: - if stop_process.is_set(): - raise BadgerRunTerminated + return False + +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 + ---------- + 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: 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 + 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 = dialog_action_queue.get( - timeout=0.1 - ) # short timeout here, so we can make checks for stop_process + msg = termination_control_queue.get(timeout=0.1) 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" + 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 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): @@ -188,15 +334,18 @@ 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 = None, log_queue: mp.Queue | None = None, - dialog_action_queue: mp.Queue | None = None, + dialog_action_queue: mp.Queue + | None = None, # receive measurement retry dialog user input + termination_control_queue: mp.Queue + | None = None, # receive termination_condition updates from gui ) -> None: """ Run the provided routine object using Xopt. This method is run as a subproccess @@ -258,9 +407,13 @@ def run_routine_subprocess( routine.environment.variables.update(routine.vrange_hard_limit) # Reset data if run_data option is False - if not args["run_data"] and routine.data is not None: - logger.info("Resetting routine data") - routine.data = routine.data.iloc[0:0] # reset the data + if not args["run_data"]: + 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}" @@ -326,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 @@ -342,71 +511,31 @@ 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() - - 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 + + # check termination_condition before checking pause_process + if check_termination_condition( + termination_condition, + start_time, + routine, + queue, + pause_process, + ): + # 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 + + 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, + ) + continue candidates = routine.generator.generate(1)[0] logger.debug(f"Generated candidates: {candidates}") @@ -417,10 +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, ) - pause_process.wait() result = evaluate_measurement_with_retry( routine, candidates, queue, stop_process, dialog_action_queue diff --git a/src/badger/errors.py b/src/badger/errors.py index c7a8efed..40b019ae 100644 --- a/src/badger/errors.py +++ b/src/badger/errors.py @@ -132,3 +132,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/action_bar.py b/src/badger/gui/components/action_bar.py index 7bb58b9f..b1b4b101 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, pyqtSignal -from PyQt5.QtGui import QFont, QIcon +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,8 +34,52 @@ def __init__(self, menu_tooltip="", parent=None): """ super().__init__(parent) self.menu_tooltip = menu_tooltip + 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: str) -> None: + self.display_text = text + self.update() + + def setDefaultAction(self, action: QAction) -> None: + super().setDefaultAction(action) + self.setIcon(action.icon()) + self.update() + + def hide_text(self) -> None: + self._last_display_text = self.display_text + self.setDisplayText("") + + def show_text(self) -> None: + self.setDisplayText(self._last_display_text) + + def initStyleOption(self, option) -> None: + super().initStyleOption(option) + if self.display_text is not None: + option.text = self.display_text + + def paintEvent(self, event: QPaintEvent) -> None: + 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): + def _over_menu_arrow(self, pos: QPoint) -> bool: opt = QStyleOptionToolButton() self.initStyleOption(opt) rect = self.style().subControlRect( @@ -43,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 @@ -110,7 +154,7 @@ def event(self, event): QToolButton { background-color: #4AB640; - color: #000000; + color: #FFFFFF; } """ @@ -132,11 +176,8 @@ def event(self, event): class BadgerActionBar(QWidget): sig_start = pyqtSignal() - sig_start_until = pyqtSignal( - 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() @@ -144,25 +185,30 @@ 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() + # signal to home_page to open termination edit dialog + sig_update_tc = pyqtSignal() sig_save_checkpoint = pyqtSignal() sig_edit_checkpoint = pyqtSignal() sig_load_checkpoint = pyqtSignal() - def __init__(self, parent=None): + 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: 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") @@ -179,10 +225,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) @@ -194,8 +236,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) @@ -203,12 +243,10 @@ 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") self.btn_stop.setFixedSize(96, 32) - self.btn_stop.setFont(cool_font) self.btn_stop.setStyleSheet(stylesheet_run) # add button for extensions @@ -240,21 +278,45 @@ 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) - 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) - menu.addAction(run_action) - menu.addAction(run_until_menu_action) - menu.addAction(run_with_data_action) - # Note: run_until_menu_action is triggered by selecting "run until" from the menu - # It emits sig_start_until(True) to launch the BadgerTerminationConditionDialog - # and sets the default run action to run_until_action. Pressing the play/stop button - # will then emit sig_start_until(False) and skip the dialog popup. + + # 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: + # 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_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_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) @@ -276,7 +338,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) @@ -291,22 +352,19 @@ 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) 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( self._on_run_until_menu_action_triggered ) - self.run_with_data_action.triggered.connect( - lambda: self.sig_run_with_data.emit() - ) + self.smart_run_action.triggered.connect(self._on_smart_run_action_triggered) + self.stop_run_action.triggered.connect(lambda: self.sig_stop.emit()) self.save_checkpoint_action.triggered.connect( lambda: self.sig_save_checkpoint.emit() ) @@ -318,154 +376,191 @@ 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) 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) - def unlock(self): + def unlock(self) -> None: self.btn_del.setDisabled(False) 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) - def routine_invalid(self): + def routine_invalid(self) -> None: 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) - + 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) - self.run_action.setText("Run") + 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.setIcon(self.icon_play_time) + self.smart_run_action.setIcon(self.icon_play) self.run_until_menu_action.setText("Run until") - self.run_until_menu_action.setIcon(self.icon_play) - # self.btn_stop.setToolTip('') + self.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) 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.setPopupMode(QToolButton.DelayedPopup) 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") + self.smart_run_action.setIcon(self.icon_pause) self.btn_checkpoint.setDisabled(False) - self.btn_ctrl.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": - self.btn_stop.setDisabled(True) - self.sig_start.emit() + 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() + 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_smart_run_action(self) -> None: + 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): + 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) -> None: + self.sig_update_tc.emit() - def _on_run_until_menu_action_triggered(self): - self.set_run_until_action(from_menu=True) + 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 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" + def handle_pause_action(self, status: bool) -> None: + """ + 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) + self.btn_checkpoint.setDisabled(False) + self.btn_set.setDisabled(True) + self.btn_reset.setDisabled(True) + + self.update_stop_menu(status) + + 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() + self.run_action.setText("New Run") + self.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.btn_stop.show_text() else: - self.sig_ctrl.emit(False) - self.btn_ctrl.setIcon(self.icon_pause) - self.btn_ctrl.setToolTip("Pause") - self.btn_ctrl._status = "pause" + self.run_menu.clear() + self.run_menu.addAction(self.stop_run_action) + if self.mini_mode: + 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=None): - """Update btn_stop tooltip: tc dict for run-until mode, or 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: 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" - self.run_until_action.setToolTip(tip) + tc_text = f"{int(tc.get('max_time', 0))} s" + tip = f"Run until: timeout = {tc_text}" 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/components/create_process.py b/src/badger/gui/components/create_process.py index a263ce87..7822e7b8 100644 --- a/src/badger/gui/components/create_process.py +++ b/src/badger/gui/components/create_process.py @@ -37,6 +37,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 @@ -56,6 +57,7 @@ def create_subprocess(self) -> None: config_path, log_queue, self.dialog_action_queue, + self.termination_control_queue, ), ) new_process.start() @@ -69,6 +71,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/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/routine_page.py b/src/badger/gui/components/routine_page.py index 024f272e..46e75483 100644 --- a/src/badger/gui/components/routine_page.py +++ b/src/badger/gui/components/routine_page.py @@ -1296,7 +1296,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/components/routine_runner.py b/src/badger/gui/components/routine_runner.py index 2efbecb7..eb41c256 100644 --- a/src/badger/gui/components/routine_runner.py +++ b/src/badger/gui/components/routine_runner.py @@ -20,18 +20,13 @@ MEASUREMENT_ACTION_RETRY, MEASUREMENT_ACTION_TYPE, MEASUREMENT_ERROR_TYPE, - TERMINATION_ACTION_CONTINUE, - TERMINATION_ACTION_END, - TERMINATION_ACTION_TYPE, + PAUSE_ACK_TYPE, TERMINATION_REACHED_TYPE, BadgerError, BadgerRunTerminated, ) 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, calculate_initial_points, calculate_variable_bounds from badger.settings import init_settings from badger.tests.utils import get_current_vars @@ -47,6 +42,10 @@ 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: @@ -92,7 +91,11 @@ 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_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 @@ -113,6 +116,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: """ @@ -123,6 +127,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 @@ -175,6 +180,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, @@ -273,13 +281,14 @@ 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" + ): + # 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) @@ -300,22 +309,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"] @@ -375,12 +374,25 @@ 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() + # 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() + 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 9a707f3c..f03d4433 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 @@ -16,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 ( @@ -31,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 @@ -72,6 +74,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 + + # 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): super().__init__() @@ -94,7 +102,8 @@ def __init__(self, process_manager: "ProcessManager | None" = 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 @@ -442,6 +451,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, @@ -457,6 +467,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) @@ -469,7 +486,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: @@ -478,10 +494,11 @@ 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( run_data_flag=run_data_flag, init_points_flag=init_points_flag ) @@ -490,6 +507,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 @@ -536,10 +555,44 @@ 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) -> None: + if self.paused: + return + + 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: + # 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}...") + def update_curves(self, results: pd.DataFrame | None = None) -> None: use_time_axis = self.plot_x_axis == 1 norm_inputs = self.x_plot_y_axis == 1 @@ -652,7 +705,7 @@ def env_ready(self, init_vars) -> None: def routine_finished(self) -> None: self.running = False - self.sig_routine_finished.emit() + self.paused = False self.sig_lock.emit(False) @@ -690,7 +743,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: @@ -731,9 +785,38 @@ def logbook(self) -> None: # QMessageBox.information( # self, 'Success!', f'') - def ctrl_routine(self, status) -> None: + 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: 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 + it has actually stopped (see sig_pause_ack), since it may still be mid-evaluation. + """ + 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: + """ + 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()) if self.vocs.constraint_names: @@ -820,6 +903,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}') @@ -987,6 +1071,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/images/play_time.png b/src/badger/gui/images/play_time.png new file mode 100644 index 00000000..b2a324d7 Binary files /dev/null and b/src/badger/gui/images/play_time.png differ 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..65603a55 --- /dev/null +++ b/src/badger/gui/mini/components/run_controller.py @@ -0,0 +1,129 @@ +"""Coordinate pause, resume, continuation, and restart behavior 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 indicates whether to load displayed data. + + def __init__(self) -> None: + super().__init__() + 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[str, object], + is_running: bool, + is_paused: bool, + data_compatible: bool, + ) -> None: + """ + Determine 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 + + # Pause the subprocess at the start of the optimization loop. + if is_running and not is_paused: + # 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 the restart logic and restart without existing data. + self.restart_override_flag = False # Reset the override flag. + else: + # 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 + ): + if is_running: + logger.info("Resuming (unpause) routine") + self.sig_pause_ctrl.emit(False) + return + else: + # The routine has ended, so start it again. + self.start_run(True) + return + + # 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 + self._load_data = True + logger.info("Pending restart queued with displayed data") + self.sig_stop.emit() + return + else: + # A previous routine exists, but no subprocess is active; start a new run. + self.start_run(True) + return + + # If variables or objectives changed, start a new optimization with the + # default number of iterations. + + # 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 the routine_finished signal. + return + + # start fresh run + self.start_run(False) + + 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 + history tree, loading a template, resetting environment variables, or dialing + in a solution. + The flag is reset in `smart_run`. + """ + self.restart_override_flag = True + + 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) -> None: + """Emit the signal to start a run with or without 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 the _pending_start flag. + self.last_routine_dict = self._new_routine_dict + self.sig_start.emit(load_displayed_data) diff --git a/src/badger/gui/mini/components/var_table.py b/src/badger/gui/mini/components/var_table.py index af885087..a70b5861 100644 --- a/src/badger/gui/mini/components/var_table.py +++ b/src/badger/gui/mini/components/var_table.py @@ -695,7 +695,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 350b9d06..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 @@ -43,6 +44,7 @@ from badger.gui.components.navigators import TemplateNavigator from badger.gui.components.run_monitor import BadgerOptMonitor from badger.gui.components.status_bar import BadgerStatusBar +from badger.gui.mini.components.run_controller import SmartRunController from badger.gui.mini.pages.routine_page import BadgerRoutinePage from badger.gui.utils import build_bax_results_file @@ -84,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() @@ -166,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) @@ -223,10 +227,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) self.routine_editor.sig_status.connect(self.update_status) @@ -244,19 +254,32 @@ 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) 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.run_monitor.reset_env + ) # on complete, emits sig_env_reset self.run_action_bar.sig_reset_env.connect( self.routine_editor.env_box.var_table.refresh_current_values ) @@ -272,32 +295,43 @@ 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_ctrl.connect(self.run_monitor.ctrl_routine) + 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) 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) + 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( + 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), 50 iterations - initial_tc = {"tc_idx": 0, "max_eval": 100, "max_time": 300, "ftol": 0} + initial_tc = {"tc_idx": 0, "max_eval": 50, "max_time": 300, "ftol": 0} self.run_monitor.save_termination_condition(initial_tc) self.run_action_bar.update_run_tooltip(initial_tc) @@ -435,7 +469,21 @@ def toggle_lock(self, lock, lock_tab=1): self.uncover_page() - def validate_loaded_data_keys(self, vocs, open_dialog: bool = True): + 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 + + 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: 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 @@ -449,19 +497,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}" ) @@ -489,6 +531,49 @@ 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) -> None: + # 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) -> 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) -> 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) -> None: + """Opens dialog to update termination condition""" + 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) -> None: + 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, @@ -521,15 +606,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 @@ -558,16 +634,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) -> None: """ 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: @@ -575,12 +648,18 @@ 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 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( @@ -599,7 +678,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, ) @@ -635,7 +713,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/mini/pages/routine_page.py b/src/badger/gui/mini/pages/routine_page.py index ebf52b69..5b42a3a8 100644 --- a/src/badger/gui/mini/pages/routine_page.py +++ b/src/badger/gui/mini/pages/routine_page.py @@ -250,7 +250,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" @@ -302,6 +302,10 @@ def set_saved_values_from_init_vars( } self.env_box.var_table.set_saved_values(values_by_name) + def get_routine_snapshot(self) -> dict: + 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: @@ -1252,7 +1256,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/windows/main_window.py b/src/badger/gui/mini/windows/main_window.py index 6f338945..06607cf0 100644 --- a/src/badger/gui/mini/windows/main_window.py +++ b/src/badger/gui/mini/windows/main_window.py @@ -107,6 +107,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, @@ -118,14 +123,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/pages/home_page.py b/src/badger/gui/pages/home_page.py index 8cda47ee..79d65790 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,11 @@ 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) @@ -287,10 +286,10 @@ def config_logic(self) -> None: 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 ) + self.run_action_bar.sig_update_tc.connect(self.start_run_until) self.sig_routine_invalid.connect(self.run_action_bar.routine_invalid) @@ -512,14 +511,6 @@ def prepare_run( # 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) @@ -551,11 +542,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 @@ -566,10 +553,18 @@ 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 + 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( @@ -588,11 +583,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) -> None: + """ + 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( @@ -606,7 +613,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/ind_lim_vrange_dialog.py b/src/badger/gui/windows/ind_lim_vrange_dialog.py index 4311b6ab..5009ac49 100644 --- a/src/badger/gui/windows/ind_lim_vrange_dialog.py +++ b/src/badger/gui/windows/ind_lim_vrange_dialog.py @@ -228,7 +228,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) @@ -281,40 +281,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), @@ -322,6 +333,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( @@ -351,31 +365,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 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, diff --git a/src/badger/gui/windows/main_window.py b/src/badger/gui/windows/main_window.py index 2199edcc..0de503b3 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, @@ -129,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() diff --git a/src/badger/gui/windows/terminition_condition_dialog.py b/src/badger/gui/windows/terminition_condition_dialog.py index d0e8e640..d4401b72 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 collections.abc import Callable +from copy import copy + from PyQt5.QtWidgets import ( QComboBox, QDialog, @@ -35,12 +38,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} @@ -62,11 +73,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"]) @@ -118,8 +132,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"]) @@ -131,8 +153,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() @@ -161,8 +187,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): @@ -172,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() 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) 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..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) @@ -286,27 +290,32 @@ 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) - monitor.start(True) - # qtbot.wait(500) + # Start a real run so pause and resume are tested against an active monitor. + paused_spy = QSignalSpy(monitor.sig_paused) + monitor.start() - qtbot.mouseClick(action_bar.btn_ctrl, Qt.MouseButton.LeftButton) + 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 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) + monitor.routine_runner.stop_routine() def test_jump_to_optimum(self, qtbot, home_page): monitor = home_page.run_monitor