diff --git a/src/badger/core_subprocess.py b/src/badger/core_subprocess.py index be94c6bd..867e770f 100644 --- a/src/badger/core_subprocess.py +++ b/src/badger/core_subprocess.py @@ -12,6 +12,7 @@ from copy import deepcopy import logging +import signal import time import traceback from typing import Any @@ -31,6 +32,10 @@ MEASUREMENT_ACTION_TYPE, MEASUREMENT_ACTION_RETRY, MEASUREMENT_ACTION_ABORT, + TERMINATION_REACHED_TYPE, + TERMINATION_ACTION_TYPE, + TERMINATION_ACTION_CONTINUE, + TERMINATION_ACTION_END, ) from badger.logger import _get_default_logger from badger.logger.event import Events @@ -43,6 +48,11 @@ logger = logging.getLogger(__name__) +def _terminate_on_sigterm(signum, frame): + # terminate cleanly if terminate signal comes before reaching stop_process check + raise BadgerRunTerminated + + def evaluate_measurement_with_retry( routine: Routine, point: Any, @@ -89,6 +99,47 @@ def evaluate_measurement_with_retry( ) +def pause_for_termination_dialog_action( + queue: mp.Queue, + stop_process: mp.Event, + pause_process: mp.Event, + dialog_action_queue: mp.Queue, + tc_condition: dict, +) -> None: + """Pause the run and wait for user action when run-until condition is reached.""" + queue.put( + { + "type": TERMINATION_REACHED_TYPE, + "tc_condition": tc_condition, + } + ) + + while True: + if stop_process.is_set(): + raise BadgerRunTerminated + + try: + msg = dialog_action_queue.get( + timeout=0.1 + ) # short timeout here, so we can make checks for stop_process + except Empty: + continue + + if ( + isinstance(msg, dict) + and msg.get("type") == TERMINATION_ACTION_TYPE + and msg.get("action") + in [TERMINATION_ACTION_CONTINUE, TERMINATION_ACTION_END] + ): + if msg["action"] == TERMINATION_ACTION_CONTINUE: + pause_process.set() + return + + raise BadgerRunTerminated( + "Run terminated after termination condition reached" + ) + + def convert_to_solution(result: DataFrame, routine: Routine): """ This method is passed the latest evaluated solution and converts that to a printable format for the terminal. @@ -275,6 +326,9 @@ def run_routine_subprocess( logger.info("Optimization started") opt_logger.update(Events.OPTIMIZATION_START, solution_meta) + # So a terminate() from the GUI still runs the shutdown path below + signal.signal(signal.SIGTERM, _terminate_on_sigterm) + # evaluate initial points: # timeout logic will be handled in the specific environment try: @@ -323,16 +377,46 @@ def run_routine_subprocess( if count >= max_eval: logger.info( - "Max evaluations reached. Terminating optimization." + "Max evaluations reached. Pausing optimization and waiting for user action." + ) + 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, + }, ) - raise BadgerRunTerminated + # reset termination condition + termination_condition = None + continue elif idx == 1: max_time = tc_config["max_time"] dt = time.time() - start_time logger.debug(f"Checking max_time termination: {dt} >= {max_time}") if dt >= max_time: - logger.info("Max time reached. Terminating optimization.") - raise BadgerRunTerminated + logger.info( + "Max time reached. Pausing optimization and waiting for user action." + ) + pause_process.clear() + pause_for_termination_dialog_action( + queue=queue, + stop_process=stop_process, + pause_process=pause_process, + dialog_action_queue=dialog_action_queue, + tc_condition={ + "type": "max_time", + "config": max_time, + "state": dt, + }, + ) + # reset termination condition + termination_condition = None + continue candidates = routine.generator.generate(1)[0] logger.debug(f"Generated candidates: {candidates}") diff --git a/src/badger/errors.py b/src/badger/errors.py index fdc42237..02a575d4 100644 --- a/src/badger/errors.py +++ b/src/badger/errors.py @@ -121,3 +121,10 @@ def __init__(self, message="Optimization run has been terminated!"): MEASUREMENT_ACTION_TYPE = "measurement_action" MEASUREMENT_ACTION_RETRY = "retry" MEASUREMENT_ACTION_ABORT = "abort" + +# Constants for run-until termination dialog feature. +# Used in communication between routine runner and subprocess. +TERMINATION_REACHED_TYPE = "termination_reached" +TERMINATION_ACTION_TYPE = "termination_action" +TERMINATION_ACTION_CONTINUE = "continue" +TERMINATION_ACTION_END = "end" diff --git a/src/badger/gui/components/action_bar.py b/src/badger/gui/components/action_bar.py index 4a0b8c5f..e39d827f 100644 --- a/src/badger/gui/components/action_bar.py +++ b/src/badger/gui/components/action_bar.py @@ -1,14 +1,48 @@ """Toolbar with run-control buttons (start, pause, stop), logbook submission, docs access, and the extensions palette launcher.""" -from PyQt5.QtWidgets import QWidget, QHBoxLayout +from PyQt5.QtWidgets import QStyle, QStyleOptionToolButton, QWidget, QHBoxLayout from PyQt5.QtWidgets import QToolButton, QMenu, QAction from PyQt5.QtGui import QIcon, QFont -from PyQt5.QtCore import pyqtSignal, QSize +from PyQt5.QtCore import QEvent, pyqtSignal, QSize from importlib import resources from badger.gui.utils import create_button from badger.gui.windows.docs_window import BadgerDocsWindow + +class SplitTooltipToolButton(QToolButton): + """ + QToolButton that shows a separate tooltip over the dropdown-arrow area. + Use arg menu_tooltip="desired tooltip" to set the menu tooltip + """ + + def __init__(self, menu_tooltip="", parent=None): + """ + Parameters + ---------- + menu_tooltip (str) + tooltip for menu + """ + super().__init__(parent) + self.menu_tooltip = menu_tooltip + + def _over_menu_arrow(self, pos): + opt = QStyleOptionToolButton() + self.initStyleOption(opt) + rect = self.style().subControlRect( + QStyle.CC_ToolButton, opt, QStyle.SC_ToolButtonMenu, self + ) + return rect.contains(pos) + + def event(self, event): + if event.type() == QEvent.ToolTip and self._over_menu_arrow(event.pos()): + from PyQt5.QtWidgets import QToolTip + + QToolTip.showText(event.globalPos(), self.menu_tooltip, self) + return True + return super().event(event) + + stylesheet_del = """ QPushButton:hover:pressed { @@ -89,7 +123,9 @@ class BadgerActionBar(QWidget): sig_start = pyqtSignal() - sig_start_until = pyqtSignal() + sig_start_until = pyqtSignal( + bool + ) # bool True launches termination condition dialog menu sig_stop = pyqtSignal() sig_delete_run = pyqtSignal() @@ -98,6 +134,7 @@ class BadgerActionBar(QWidget): sig_jump_to_optimal = pyqtSignal() sig_dial_in = pyqtSignal() sig_ctrl = pyqtSignal(bool) + sig_run_with_data = pyqtSignal() sig_open_extensions_palette = pyqtSignal() sig_save_checkpoint = pyqtSignal() @@ -160,7 +197,7 @@ def load_internal_icon(name: str) -> QIcon: self.btn_ctrl.setDisabled(True) # self.btn_stop = btn_stop = QPushButton('Run') - self.btn_stop = QToolButton() + self.btn_stop = SplitTooltipToolButton(menu_tooltip="Run Options Menu") self.btn_stop.setFixedSize(96, 32) self.btn_stop.setFont(cool_font) self.btn_stop.setStyleSheet(stylesheet_run) @@ -198,15 +235,24 @@ def load_internal_icon(name: str) -> QIcon: run_action.setIcon(self.icon_play) self.run_until_action = run_until_action = QAction("Run until", self) run_until_action.setIcon(self.icon_play) + self.run_until_menu_action = run_until_menu_action = QAction("Run until", self) + run_until_menu_action.setIcon(self.icon_play) + 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_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. # Set the menu as the run button's dropdown menu self.btn_stop.setMenu(menu) self.btn_stop.setDefaultAction(run_action) self.btn_stop.setPopupMode(QToolButton.MenuButtonPopup) self.btn_stop.setDisabled(False) - # btn_stop.setToolTip('') + run_action.setToolTip("Run") # Config button self.btn_config = btn_config = create_button("tools.png", "Configure run") @@ -244,8 +290,14 @@ def config_logic(self): self.btn_opt.clicked.connect(self.jump_to_optimal) self.btn_set.clicked.connect(self.dial_in) self.btn_ctrl.clicked.connect(self.ctrl_routine) - self.run_action.triggered.connect(self.set_run_action) - self.run_until_action.triggered.connect(self.set_run_until_action) + self.run_action.triggered.connect(self._on_run_action_triggered) + self.run_until_action.triggered.connect(self._on_run_until_action_triggered) + self.run_until_menu_action.triggered.connect( + self._on_run_until_menu_action_triggered + ) + self.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() ) @@ -293,6 +345,8 @@ def routine_finished(self): self.run_action.setIcon(self.icon_play) self.run_until_action.setText("Run until") self.run_until_action.setIcon(self.icon_play) + self.run_until_menu_action.setText("Run until") + self.run_until_menu_action.setIcon(self.icon_play) # self.btn_stop.setToolTip('') self.btn_stop.setDisabled(False) @@ -320,6 +374,8 @@ def run_start(self): self.run_action.setIcon(self.icon_stop) self.run_until_action.setText("Stop") self.run_until_action.setIcon(self.icon_stop) + self.run_until_menu_action.setText("Stop") + self.run_until_menu_action.setIcon(self.icon_stop) self.btn_checkpoint.setDisabled(False) self.btn_ctrl.setDisabled(False) self.btn_set.setDisabled(True) @@ -335,16 +391,25 @@ def set_run_action(self): self.btn_stop.setDisabled(True) self.sig_stop.emit() - def set_run_until_action(self): + def set_run_until_action(self, from_menu=False): if self.btn_stop.defaultAction() is not self.run_until_action: self.btn_stop.setDefaultAction(self.run_until_action) if self.run_until_action.text() == "Run until": - self.sig_start_until.emit() + self.sig_start_until.emit(from_menu) else: self.btn_stop.setDisabled(True) self.sig_stop.emit() + def _on_run_action_triggered(self): + self.set_run_action() + + def _on_run_until_action_triggered(self): + self.set_run_until_action(from_menu=False) + + def _on_run_until_menu_action_triggered(self): + self.set_run_until_action(from_menu=True) + def delete_run(self): self.sig_delete_run.emit() @@ -382,3 +447,16 @@ def open_extensions_palette(self): def env_ready(self): self.btn_log.setDisabled(False) self.btn_opt.setDisabled(False) + + def update_run_tooltip(self, tc=None): + """Update btn_stop tooltip: tc dict for run-until mode, or None.""" + if tc is None: + self.run_action.setToolTip("Run") + else: + tc_idx = tc.get("tc_idx", 0) + if tc_idx == 0: + tip = f"Run until: n iterations = {tc.get('max_eval')}" + elif tc_idx == 1: + tip = f"Run until: timeout = {tc.get('max_time')}s" + self.run_until_action.setToolTip(tip) + self.run_until_menu_action.setToolTip(tip) diff --git a/src/badger/gui/components/routine_runner.py b/src/badger/gui/components/routine_runner.py index 63e874cd..968cb5cf 100644 --- a/src/badger/gui/components/routine_runner.py +++ b/src/badger/gui/components/routine_runner.py @@ -22,12 +22,19 @@ MEASUREMENT_ACTION_TYPE, MEASUREMENT_ACTION_RETRY, MEASUREMENT_ACTION_ABORT, + TERMINATION_REACHED_TYPE, + TERMINATION_ACTION_TYPE, + TERMINATION_ACTION_CONTINUE, + TERMINATION_ACTION_END, ) from badger.tests.utils import get_current_vars from badger.routine import calculate_variable_bounds, calculate_initial_points from badger.settings import init_settings from badger.gui.components.process_manager import ProcessManager from badger.gui.windows.measurement_retry_dialog import BadgerMeasurementRetryDialog +from badger.gui.windows.termination_reached_dialog import ( + BadgerTerminationReachedDialog, +) from badger.routine import Routine logger = logging.getLogger(__name__) @@ -40,6 +47,7 @@ class BadgerRoutineSignals(QObject): error = pyqtSignal(Exception) info = pyqtSignal(str) states = pyqtSignal(str) + sig_status = pyqtSignal(str) # status message information class BadgerRoutineSubprocess: @@ -261,6 +269,17 @@ def check_queue(self) -> None: "action": action, } ) + elif ( + isinstance(msg, dict) + and msg.get("type") == TERMINATION_REACHED_TYPE + ): + action = self.handle_termination_reached(msg) + self.dialog_action_queue.put( + { + "type": TERMINATION_ACTION_TYPE, + "action": action, + } + ) else: error_title, error_traceback = msg BadgerError(error_title, error_traceback) @@ -281,6 +300,33 @@ 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 + 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 + + def _format_tc_status_str(self, tc_condition: dict) -> str: + tc_type = tc_condition["type"] + if tc_type == "max_eval": + tc_type_text = "N iterations" + state = tc_condition["state"] + else: + tc_type_text = "timeout" + state = f"{tc_condition['state']:.2f} s" + return f"Routine {self.routine.name} paused: Condition {tc_type_text} = {state} reached" + def after_evaluate(self, results: pd.DataFrame) -> None: logger.debug("Received evaluation results from subprocess.") """ @@ -329,8 +375,10 @@ def ctrl_routine(self, pause: bool) -> None: pause : bool """ if pause: + self.signals.sig_status.emit(f"Routine {self.routine.name} paused") self.pause_event.clear() else: + self.signals.sig_status.emit(f"Running routine {self.routine.name}...") self.pause_event.set() def close(self) -> None: diff --git a/src/badger/gui/components/run_monitor.py b/src/badger/gui/components/run_monitor.py index b5e2e7bf..bfba360f 100644 --- a/src/badger/gui/components/run_monitor.py +++ b/src/badger/gui/components/run_monitor.py @@ -454,6 +454,7 @@ def init_routine_runner(self): routine_runner.signals.error.connect(self.on_error) routine_runner.signals.info.connect(self.on_info) routine_runner.signals.states.connect(self.states) + routine_runner.signals.sig_status.connect(self.sig_status.emit) self.sig_pause.connect(routine_runner.ctrl_routine) self.sig_stop.connect(routine_runner.stop_routine) diff --git a/src/badger/gui/mini/pages/home_page.py b/src/badger/gui/mini/pages/home_page.py index e9b7989b..97003a4d 100644 --- a/src/badger/gui/mini/pages/home_page.py +++ b/src/badger/gui/mini/pages/home_page.py @@ -11,6 +11,7 @@ import traceback from importlib import resources +import numpy as np from pandas import DataFrame from PyQt5.QtCore import pyqtSignal, Qt, QModelIndex from PyQt5.QtGui import QIcon @@ -29,6 +30,8 @@ get_runs, save_tmp_run, ) +from badger.errors import BadgerRoutineError +from badger.gui.components.data_panel import filter_metadata from badger.gui.components.data_table import ( add_row, data_table, @@ -277,12 +280,30 @@ def config_logic(self): self.routine_editor.env_box.var_table.refresh_current_values ) self.run_action_bar.sig_ctrl.connect(self.run_monitor.ctrl_routine) + self.run_action_bar.sig_run_with_data.connect( + lambda: self.start_run( + use_termination_condition=bool(self.run_monitor.termination_condition), + load_displayed_data=True, + ) + ) self.run_action_bar.sig_open_extensions_palette.connect( self.run_monitor.open_extensions_palette ) self.sig_routine_invalid.connect(self.run_action_bar.routine_invalid) + self._configure_default_run_action() + + def _configure_default_run_action(self): + """Set the default run action as run_until_action""" + self.run_action_bar.btn_stop.setDefaultAction( + self.run_action_bar.run_until_action + ) + # configure default to max_eval (tc_idx=0), 50 iterations + 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) + def update_saved_values_from_monitor(self): """ Sync Saved column values to match run monitor reset_env targets. @@ -417,7 +438,61 @@ def toggle_lock(self, lock, lock_tab=1): self.uncover_page() - def prepare_run(self): + def validate_loaded_data_keys(self, vocs, open_dialog: bool = True): + """ + This function is called when adding historical data to a new routine. + It makes sure that the keys of data to be loaded match the + selected variables and objectives in VOCS. If they do not, raises an error. + If the set of data keys matches provided VOCS variables + and objectives, opens a dialog to inform user that data has been added. + + Args: + vocs: VOCS + """ + # get routine selected from data_panel + routine = self.current_routine + + # Want to compare variables, objectives + loaded_data_vars_objs_names = ( + routine.vocs.variable_names + routine.vocs.objective_names + ) + + # Raise error if loaded data keys do not match selected vocs + if set(loaded_data_vars_objs_names) != set( + vocs.variable_names + vocs.objective_names + ): + self.run_action_bar.routine_finished() # Reset action bar + raise BadgerRoutineError( + "Keys in loaded data do not match selected VOCS:\n\n" + + f"Keys in data to load:\n {loaded_data_vars_objs_names}\n\n" + + f"Selected VOCS:\n {vocs.variable_names + vocs.objective_names}" + ) + + df = routine.sorted_data + data = df.to_dict(orient="list") + data = filter_metadata(data) + data_keys = data.keys() + + if open_dialog: + # Notify user that data has been added to the routine + dialog = QMessageBox( + text=str( + "Data loaded into routine for the following VOCS:\n\n" + + f"{list(data_keys)}\n\n" + + "Click OK to continue!" + ), + parent=self, + ) + dialog.setIcon(QMessageBox.Information) + dialog.setWindowTitle("Data added to routine") + dialog.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) + result = dialog.exec_() + + if result == QMessageBox.Cancel: + self.run_action_bar.routine_finished() # Reset action bar + raise BadgerRoutineError("Routine initialization cancelled by user.") + + def prepare_run(self, data=None, init_points_flag=True): """ Prepares the run by composing the routine, validating data if present, saving created routine to a yaml file, and passing the routine to @@ -429,10 +504,6 @@ def prepare_run(self): confirm that initial points are being sampled if there are new columns in the dataframe. If there are new columns and the flag is false, this function raises an error. - - Notes - _____ - Removed data loading implementation from mini GUI, """ logger.info("Preparing new run.") try: @@ -441,6 +512,35 @@ def prepare_run(self): self.sig_routine_invalid.emit() raise e + # Add data to routine before saving tmp file + if data is not None: + # Make sure selected generator is compatible with prior data + if routine.generator.name in ["neldermead"]: + self.run_action_bar.routine_finished() # Reset action bar + # TODO: update error message and/or support neldermead for resume function + raise BadgerRoutineError( + "Neldermead algorithm is not compatible with data loading. " + + "\nPlease uncheck 'Load displayed data into routine' " + + "or select a different algorithm." + ) + # Check that routine variables and objectives match loaded data + self.validate_loaded_data_keys(routine.vocs, open_dialog=False) + data["live"] = 0 # reset live data indicator for loaded data + for name in routine.vocs.output_names: + if name not in data.columns: + # Add null datapoints for new constraints or observables + data[name] = np.nan + + # Raise error if there are new columns (all NaN) and no initial points selected + if data.isna().all().any() and not init_points_flag: + self.run_action_bar.routine_finished() # Reset action bar + raise BadgerRoutineError( + "Must select at least one initial point in order to add" + + " new constraints to routine!" + ) + + routine.data = data + self.current_routine = routine # Save routine as a temp file @@ -451,7 +551,9 @@ def prepare_run(self): # Tell monitor to start the run self.run_monitor.init_plots(routine) - def start_run(self, use_termination_condition: bool = False): + def start_run( + self, use_termination_condition: bool = False, load_displayed_data: bool = False + ): """ Prepares and starts optimization run with provided options. - Termination Condition is provided when called via BadgerTerminationConditionDialog @@ -459,34 +561,74 @@ def start_run(self, use_termination_condition: bool = False): Args: use_termination_condition (bool): Is set as True if called from BadgerTerminationConditionDialog. + load_displayed_data (bool): If True loads data from the currently displayed routine. Notes: Removed data loading implementation and data_panel from mini GUI """ logger.info("Starting run.") - self.prepare_run() + # flags for loading data + run_data_flag = load_displayed_data + init_points_flag = True + if run_data_flag: + init_points_flag = False + + if run_data_flag: + data_to_load = self.load_data_from_run() + self.prepare_run( + data=data_to_load, + init_points_flag=init_points_flag, + ) # Pass data to prepare run, to be saved to tmp file and loaded into plots + self.run_monitor.init_plots(self.current_routine) + + # Add routine and generator data back to the routine + self.current_routine.data = data_to_load + if self.current_routine.generator.data is None: + self.current_routine.generator.data = data_to_load + else: + # run data flag is False + + self.prepare_run() self.run_monitor.start( use_termination_condition=use_termination_condition, - run_data_flag=False, - init_points_flag=True, + run_data_flag=run_data_flag, + init_points_flag=init_points_flag, ) - def start_run_until(self): + def load_data_from_run(self): + return self.current_routine.sorted_data + + def start_run_until(self, dialog: bool = True): + """ + Starts run with termination condition. + + Args: + dialog (bool): If True, opens dialog popup for selecting termination condition. + If False skips dialog and uses the tc_config which + was previously saved in the run_monitor. + + Notes: If no tc_config is found, opens popup regardless of dialog flag. + """ logger.info("Starting run until condition met.") - dlg = BadgerTerminationConditionDialog( - self, - self.start_run, - self.run_monitor.save_termination_condition, - self.run_monitor.termination_condition, - ) - self.tc_dialog = dlg - try: - dlg.exec() - finally: - self.tc_dialog = None - # self.run_monitor.start_until() + if dialog or not self.run_monitor.termination_condition: + dlg = BadgerTerminationConditionDialog( + self, + self.start_run, + self.run_monitor.save_termination_condition, + self.run_monitor.termination_condition, + ) + self.tc_dialog = dlg + try: + dlg.exec() + finally: + self.tc_dialog = None + self.run_action_bar.update_run_tooltip( + self.run_monitor.termination_condition + ) + else: + self.start_run(use_termination_condition=True) def new_run(self): logger.info("Creating new run.") @@ -501,8 +643,11 @@ def new_run(self): def run_name(self, name): logger.info(f"Updating run name: {name}") runs = get_runs() + # block signals on update after routine finished, since the selected run is already diplayed + self.history_browser.history_tree_widget.blockSignals(True) self.history_browser.updateItems(runs) self.history_browser._selectItemByRun(name) + self.history_browser.history_tree_widget.blockSignals(False) def update_status(self, info): logger.info(f"Updating status: {info}") diff --git a/src/badger/gui/windows/termination_reached_dialog.py b/src/badger/gui/windows/termination_reached_dialog.py new file mode 100644 index 00000000..af5d76b6 --- /dev/null +++ b/src/badger/gui/windows/termination_reached_dialog.py @@ -0,0 +1,110 @@ +"""Dialog shown when a run-until threshold is reached during optimization. + +Lets users choose whether to continue running or end the current run, +after the run is paused by a termination condition. +""" + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QDialog, + QDialogButtonBox, + QHBoxLayout, + QLabel, + QVBoxLayout, +) + +stylesheet_run = """ +QPushButton:hover:pressed +{ + background-color: #92D38C; +} +QPushButton:hover +{ + background-color: #6EC566; +} +QPushButton +{ + background-color: #4AB640; + color: #000000; +} +""" + +stylesheet_stop = """ +QPushButton:hover:pressed +{ + background-color: #C7737B; +} +QPushButton:hover +{ + background-color: #BF616A; +} +QPushButton +{ + background-color: #A9444E; +} +""" + + +class BadgerTerminationReachedDialog(QDialog): + def __init__(self, tc_condition=None, text="", parent=None): + super().__init__(parent) + + self.setWindowTitle("Termination Condition Reached") + self.setMinimumWidth(360) + + layout = QVBoxLayout(self) + layout.setContentsMargins(14, 14, 14, 14) + layout.setSpacing(8) + + tc_type = tc_condition["type"] + if tc_type == "max_eval": + tc_type_text = "Max evaluation" + state = tc_condition["state"] + else: + tc_type_text = "Timeout" + state = f"{tc_condition['state']:.2f} s" + + content_row = QHBoxLayout() + content_row.setSpacing(6) + + text_column = QVBoxLayout() + text_column.setSpacing(3) + + title_label = QLabel("Termination condition reached") + title_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + title_label.setStyleSheet("font-size: 14px; font-weight: 600;") + text_column.addWidget(title_label) + + body_label = QLabel("Badger optimization stopped.") + body_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + text_column.addWidget(body_label) + + summary_label = QLabel(f"{tc_type_text}: {state}/{tc_condition['config']}") + summary_label.setWordWrap(True) + summary_label.setAlignment(Qt.AlignLeft) + summary_label.setStyleSheet("color: #8A949E;") + text_column.addWidget(summary_label) + + content_row.addLayout(text_column) + layout.addLayout(content_row) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.continueButton = button_box.button(QDialogButtonBox.Ok) + self.endButton = button_box.button(QDialogButtonBox.Cancel) + + font = self.font() + font.setPointSize(12) + self.setFont(font) + + self.continueButton.setText("Continue") + # self.continueButton.setStyleSheet(stylesheet_run) + self.continueButton.setFixedSize(96, 24) + self.endButton.setText("End Run") + self.endButton.setStyleSheet(stylesheet_stop) + self.endButton.setFixedSize(96, 24) + + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + self.resize(360, 150) diff --git a/src/badger/routine.py b/src/badger/routine.py index 0ab501ec..cca84e8e 100644 --- a/src/badger/routine.py +++ b/src/badger/routine.py @@ -34,6 +34,11 @@ from badger.environment import BaseEnvironment, instantiate_env from badger.factory import get_env +# Import xopt.generators at startup so they don't need to be imported +# each time a Routine is created +import xopt.generators.bayesian # noqa: F401 +import xopt.generators.sequential # noqa: F401 + logger = logging.getLogger(__name__)