diff --git a/rascal2/core/runner.py b/rascal2/core/runner.py index 7e18f2cf..4aedd3c5 100644 --- a/rascal2/core/runner.py +++ b/rascal2/core/runner.py @@ -3,11 +3,10 @@ import os from dataclasses import dataclass from logging import INFO -from multiprocessing import Process, Queue +from multiprocessing import Event, Process, Queue, cpu_count import ratapi as rat from PyQt6 import QtCore -from ratapi.utils.enums import Procedures from rascal2.config import MatlabHelper, get_matlab_engine @@ -18,75 +17,162 @@ class RATRunner(QtCore.QObject): event_received = QtCore.pyqtSignal() finished = QtCore.pyqtSignal() stopped = QtCore.pyqtSignal() + go_event = Event() + processes_list_go_exit_events = [] - def __init__(self, rat_inputs, procedure: Procedures, display_on: bool): + def __init__(self, parent=None, start_runners_early: bool = True, num_cores: int = cpu_count()): super().__init__() + self.parent = parent self.timer = QtCore.QTimer() self.timer.setInterval(1) self.timer.timeout.connect(self.check_queue) + self.matlab_helper = MatlabHelper() + self.num_cores = num_cores + self.start_runners_early = start_runners_early # this queue handles both event data and results self.queue = Queue() - matlab_helper = MatlabHelper() - self.process = Process( - target=run, - args=( - self.queue, - rat_inputs, - procedure, - display_on, - matlab_helper.ready_event, - matlab_helper.engine_output, - ), - ) + self.arg_queue = Queue() + self.go_event = Event() + self.exit_event = Event() + self.rat_inputs = None + self.procedure = None + self.display_on = None + self.processes_list = [] + self.refresh_process_list() + self.process = None self.updated_problem = None self.results = None self.error = None self.events = [] + self.engine_future = None + + def set_runner_args(self, rat_inputs, procedure, display_on: bool): + self.arg_queue.put((rat_inputs, procedure, display_on)) + self.rat_inputs = rat_inputs + self.display_on = display_on def start(self): """Start the calculation.""" - self.process.start() + self.process, (self.go_event, self.exit_event) = self.get_new_process() + if self.engine_future is None: + self.get_runner_matlab_engine() + self.go_event.set() + if not self.process.is_alive(): + self.process.start() self.timer.start() + def get_new_process(self): + if not self.processes_list: + self.refresh_process_list() + return self.processes_list.pop(0), self.processes_list_go_exit_events.pop(0) + + def get_runner_matlab_engine(self): + problem_definition, cpp_controls = self.rat_inputs + if any([file["language"] == "matlab" for file in problem_definition.customFiles.files]): + engine_ready = (self.matlab_helper.ready_event,) + engine_output = self.matlab_helper.engine_output + matlab_queue = Queue() + get_runner_matlab_engine_process = Process( + target=run_matlab_init_engine, + args=(matlab_queue, engine_output, engine_ready, self.display_on), + ) + get_runner_matlab_engine_process.start() + get_runner_matlab_engine_process.join() + self.engine_future = self.filter_queue(matlab_queue) + def interrupt(self): """Interrupt the running process.""" self.timer.stop() self.process.kill() self.stopped.emit() + self.go_event.clear() def check_queue(self): """Check for new data in the queue.""" if not self.process.is_alive(): self.timer.stop() - self.queue.put(None) - for item in iter(self.queue.get, None): + self.filter_queue(self.queue) + + def filter_queue(self, queue: Queue): + queue.put(None) + for item in iter(queue.get, None): if isinstance(item, tuple): self.updated_problem, self.results = item + self.go_event.clear() self.finished.emit() elif isinstance(item, Exception): self.error = item + self.go_event.clear() self.stopped.emit() + elif isinstance(item, list): + return item[0] else: # else, assume item is an event self.events.append(item) self.event_received.emit() - -def run(queue, rat_inputs: tuple, procedure: str, display: bool, engine_ready, engine_output): + def refresh_process_list(self): + self.processes_list_go_exit_events = [(Event(), Event()) for _ in range(self.num_cores)] + self.processes_list = [ + Process( + target=run, + args=( + self.queue, + self.arg_queue, + self.processes_list_go_exit_events[ind][0], + self.processes_list_go_exit_events[ind][1], + ), + ) + for ind in range(self.num_cores) + ] + + def clear_queues(self): + self.queue.empty() + self.arg_queue.empty() + self.events.clear() + self.go_event.clear() + self.exit_event.clear() + + def start_processes(self): + if self.start_runners_early: + for process in self.processes_list: + process.start() + + def stop_processes(self): + self.exit_event.set() + self.go_event.set() + for go_event, exit_event in self.processes_list_go_exit_events: + exit_event.set() + go_event.set() + for process in self.processes_list: + if process.is_alive(): + process.kill() + self.processes_list.clear() + self.clear_queues() + self.processes_list_go_exit_events.clear() + self.queue.close() + self.arg_queue.close() + if self.engine_future is not None: + self.engine_future.result().exit() + self.matlab_helper.close_event.set() + + +def run(queue: Queue, arg_queue: Queue, go_event, exit_event): """Run RAT and put the result into the queue. Parameters ---------- queue : Queue The interprocess queue for the RATRunner. - rat_inputs : tuple - The C++ inputs for rat. - procedure : str - The optimisation procedure. - display : bool - Whether to display events. + arg_queue : + A queue of arguments used to initialize the RAT process, passed from the Main Presenter """ + go_event.wait() + if exit_event.is_set(): + queue.put(LogData(INFO, "exit_event triggers")) + return + rat_inputs, procedure, display = arg_queue.get() problem_definition, cpp_controls = rat_inputs if display: @@ -96,22 +182,10 @@ def run(queue, rat_inputs: tuple, procedure: str, display: bool, engine_ready, e queue.put(LogData(INFO, "Starting RAT")) try: - engine_future = None - if any([file["language"] == "matlab" for file in problem_definition.customFiles.files]): - if not engine_output: - queue.put(LogData(INFO, "Attempting to start Matlab...")) - - result = get_matlab_engine(engine_ready, engine_output) - if isinstance(result, Exception): - raise result - else: - engine_future = result - engine_future.result().cd(os.getcwd()) - problem_definition, output_results, bayes_results = rat.rat_core.RATMain(problem_definition, cpp_controls) + if display: + queue.put(LogData(INFO, "Creating RAT Results...")) results = rat.outputs.make_results(procedure, output_results, bayes_results) - if engine_future is not None: - engine_future.result().exit() except Exception as err: queue.put(err) return @@ -123,6 +197,27 @@ def run(queue, rat_inputs: tuple, procedure: str, display: bool, engine_ready, e queue.put((problem_definition, results)) +def run_matlab_init_engine(queue, engine_output, engine_ready, display_on): + """Get the engine future from the matlab engine and put in queue if successfully.""" + try: + if not engine_output and display_on: + queue.put(LogData(INFO, "Attempting to start Matlab...")) + + result = get_matlab_engine(engine_ready, engine_output) + if display_on: + queue.put(LogData(INFO, "Got Matlab engine")) + if isinstance(result, Exception): + raise result + else: + engine_future = result + engine_future.result().cd(os.getcwd()) + queue.put([engine_future]) + + except Exception as err: + queue.put(err) + return + + @dataclass class LogData: """Dataclass for logging data.""" diff --git a/rascal2/ui/presenter.py b/rascal2/ui/presenter.py index 0f9e80e6..5a8270c3 100644 --- a/rascal2/ui/presenter.py +++ b/rascal2/ui/presenter.py @@ -5,6 +5,7 @@ import ratapi as rat import ratapi.wrappers +from PyQt6.QtCore import QCoreApplication from rascal2.config import LOGGER, SETTINGS, MatlabHelper from rascal2.core import commands @@ -15,6 +16,8 @@ from .model import MainWindowModel +START_PROCESSES = bool(os.getenv("START_PROCESSES", "True")) + class MainWindowPresenter: """Facilitates interaction between View and Model. @@ -29,6 +32,11 @@ def __init__(self, view): self.view = view self.model = MainWindowModel() self.worker = None + self.runner = RATRunner(self, start_runners_early=START_PROCESSES) + self.runner.finished.connect(self.handle_results) + self.runner.stopped.connect(self.handle_interrupt) + self.runner.event_received.connect(self.handle_event) + self.runner.start_processes() def create_project(self, name: str, save_path: str): """Create a new RAT project and controls object then initialise UI. @@ -229,11 +237,7 @@ def run(self): self.model.controls.initialise_IPC() rat_inputs = rat.inputs.make_input(self.model.project, self.model.controls) display_on = self.model.controls.display != rat.utils.enums.Display.Off - - self.runner = RATRunner(rat_inputs, self.model.controls.procedure, display_on) - self.runner.finished.connect(self.handle_results) - self.runner.stopped.connect(self.handle_interrupt) - self.runner.event_received.connect(self.handle_event) + self.runner.set_runner_args(rat_inputs, self.model.controls.procedure, display_on) self.view.terminal_widget.write("Initializing RAT Process...") self.runner.start() @@ -249,6 +253,7 @@ def handle_results(self): ) self.view.handle_results(self.runner.results) self.model.controls.delete_IPC() + self.runner.clear_queues() def handle_interrupt(self): """Handle a RAT run being interrupted.""" @@ -274,6 +279,7 @@ def handle_event(self): self.view.plot_widget.plot_with_blit(event) case LogData(): LOGGER.log(event.level, event.msg) + QCoreApplication.processEvents() def edit_project(self, updated_project: dict, preview: bool = True) -> None: """Edit the Project with a dictionary of attributes. diff --git a/rascal2/ui/view.py b/rascal2/ui/view.py index 6325910b..7af051dd 100644 --- a/rascal2/ui/view.py +++ b/rascal2/ui/view.py @@ -81,6 +81,8 @@ def closeEvent(self, event): event.accept() else: event.ignore() + self.presenter.runner.stop_processes() + event.accept() def show_project_dialog(self, dialog: StartupDialog): """Show a startup dialog of a given type. diff --git a/tests/conftest.py b/tests/conftest.py index ff31b498..a1f2f688 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,18 @@ def global_setting(): return GLOBAL_SETTING +@pytest.fixture(autouse=True) +@patch("rascal2.core.runner.cpu_count") +def fix_cpu_count(cpu_count): + cpu_count.return_value = 1 + yield + + +@pytest.fixture(scope="function", autouse=True) +def mock_start_processes_setting(monkeypatch): + monkeypatch.setenv("START_PROCESSES", "False") + + @pytest.fixture(scope="session", autouse=True) def mock_setting(request): global GLOBAL_SETTING diff --git a/tests/core/test_runner.py b/tests/core/test_runner.py index 339f43d3..258d52c5 100644 --- a/tests/core/test_runner.py +++ b/tests/core/test_runner.py @@ -2,6 +2,7 @@ import contextlib import os +from multiprocessing import Event from queue import Queue # we need a non-multiprocessing queue because mocks cannot be serialised from unittest.mock import MagicMock, patch @@ -33,29 +34,48 @@ def mock_rat_main(*args, **kwargs): return 1, 2, 3 +def close_processes(runner): + # Non serialised queue does not have a close attribute so have to mock it out + runner.queue.close = MagicMock() + runner.arg_queue.close = MagicMock() + runner.stop_processes() + + @patch("rascal2.core.runner.MatlabHelper", autospec=True) @patch("rascal2.core.runner.Process") -def test_start(mock_process, mock_matlab): +@patch("rascal2.core.runner.RATRunner.get_new_process") +def test_start(mock_process_go_exit, mock_process, mock_matlab): """Test that `start` creates and starts a process and timer.""" mock_matlab.return_value = MagicMock() - runner = RATRunner(make_rat_input(), "", True) + mock_go = MagicMock() + mock_process_go_exit.return_value = MagicMock(), (mock_go, MagicMock()) + runner = RATRunner(start_runners_early=False, num_cores=1) + runner.process = MagicMock() + runner.get_runner_matlab_engine = MagicMock() + runner.set_runner_args(make_rat_input(), "", True) runner.start() - runner.process.start.assert_called_once() + mock_go.set.assert_called_once() assert runner.timer.isActive() + close_processes(runner) + @patch("rascal2.core.runner.MatlabHelper", autospec=True) @patch("rascal2.core.runner.Process") def test_interrupt(mock_process, mock_matlab): """Test that `interrupt` kills the process and stops the timer.""" mock_matlab.return_value = MagicMock() - runner = RATRunner([], "", True) + runner = RATRunner(start_runners_early=False, num_cores=1) + runner.process = MagicMock() + runner.set_runner_args([], "", True) runner.interrupt() runner.process.kill.assert_called_once() assert not runner.timer.isActive() + close_processes(runner) + @pytest.mark.parametrize( "queue_items", @@ -73,7 +93,10 @@ def test_interrupt(mock_process, mock_matlab): def test_check_queue(mock_process, mock_matlab, queue_items): """Test that queue data is appropriately assigned.""" mock_matlab.return_value = MagicMock() - runner = RATRunner([], "", True) + runner = RATRunner(start_runners_early=False, num_cores=1) + runner.process = MagicMock() + runner.get_runner_matlab_engine = MagicMock() + runner.set_runner_args([], "", True) runner.queue = Queue() for item in queue_items: @@ -95,18 +118,25 @@ def test_check_queue(mock_process, mock_matlab, queue_items): assert isinstance(runner.error, ValueError) assert str(runner.error) == "Runner error!" + close_processes(runner) + @patch("rascal2.core.runner.MatlabHelper", autospec=True) @patch("rascal2.core.runner.Process") def test_empty_queue(mock_process, mock_matlab): """Test that nothing happens if the queue is empty.""" mock_matlab.return_value = MagicMock() - runner = RATRunner(make_rat_input(), "", True) + runner = RATRunner(start_runners_early=False, num_cores=1) + runner.process = MagicMock() + runner.set_runner_args(make_rat_input(), "", True) + runner.check_queue() assert len(runner.events) == 0 assert runner.results is None + close_processes(runner) + @pytest.mark.parametrize("display", [True, False]) @patch("ratapi.rat_core.RATMain", new=mock_rat_main) @@ -114,7 +144,12 @@ def test_empty_queue(mock_process, mock_matlab): def test_run(display): """Test that a run puts the correct items in the queue.""" queue = Queue() - run(queue, make_rat_input(), "", display, None, None) + arg_queue = Queue() + arg_queue.put((make_rat_input(), "", display)) + go_event, exit_event = (Event(), Event()) + go_event.set() + run(queue, arg_queue, go_event, exit_event) + expected_display = [ LogData(20, "Starting RAT"), 0.2, @@ -122,6 +157,7 @@ def test_run(display): "test message", "test message 2", 0.7, + LogData(20, "Creating RAT Results..."), LogData(20, "Finished RAT"), ] @@ -147,7 +183,11 @@ def erroring_ratmain(*args): queue = Queue() with patch("ratapi.rat_core.RATMain", new=erroring_ratmain): - run(queue, make_rat_input(), "", True, None, None) + args_queue = Queue() + args_queue.put((make_rat_input(), "", True)) + go_event, exit_event = (Event(), Event()) + go_event.set() + run(queue, args_queue, go_event, exit_event) queue.put(None) queue_contents = list(iter(queue.get, None)) @@ -172,7 +212,11 @@ def test_run_examples(example): rat_inputs = rat.inputs.make_input(project, rat.Controls()) queue = Queue() - run(queue, rat_inputs, "calculate", False, None, None) + args_queue = Queue() + args_queue.put((rat_inputs, "calculate", False)) + go_event, exit_event = (Event(), Event()) + go_event.set() + run(queue, args_queue, go_event, exit_event) output = queue.get() diff --git a/tests/test_ui.py b/tests/test_ui.py index ae90d9ea..88b57760 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -35,12 +35,17 @@ def test_integration(qt_application, make_main_window): _ = qt_application window = make_main_window() window.show() + print("test_integration 1") window.presenter.create_project("project", ".") + print("test_integration 2") names = [win.windowTitle() for win in window.mdi.subWindowList()] + print("test_integration 3") # QMDIArea is first in last out hence the reversed list assert names == ["Fitting Controls", "Terminal", "Project", "Plots"] + print("test_integration 4") # Work through the different sections of the UI window.close() + print("test_integration 5") diff --git a/tests/ui/test_presenter.py b/tests/ui/test_presenter.py index 0d85e053..151f83e0 100644 --- a/tests/ui/test_presenter.py +++ b/tests/ui/test_presenter.py @@ -53,9 +53,9 @@ def presenter(): with ( patch("rascal2.ui.presenter.LOGGER", autospec=True) as mock_log, patch("rascal2.ui.model.os.chdir", autospec=True), + patch("rascal2.ui.presenter.RATRunner", autospec=True, return_value=MagicMock()), ): pr = MainWindowPresenter(MockWindowView()) - pr.runner = MagicMock() pr.model.controls = Controls() pr.model.project = MagicMock() pr.model.project.name = "test_name" diff --git a/tests/ui/test_view.py b/tests/ui/test_view.py index e9754f8a..400e561c 100644 --- a/tests/ui/test_view.py +++ b/tests/ui/test_view.py @@ -33,6 +33,7 @@ def test_view(): with ( patch("rascal2.widgets.plot.FigureCanvasQTAgg", return_value=MockFigureCanvas()), patch("rascal2.widgets.plot.NavigationToolbar2QT", return_value=MockNavigationToolbar()), + patch("rascal2.ui.presenter.RATRunner", autospec=True, return_value=MagicMock()), ): yield MainWindowView() @@ -116,7 +117,8 @@ def test_set_enabled(test_view): @patch("PyQt6.QtWidgets.QFileDialog.getExistingDirectory") @patch("rascal2.ui.view.get_global_settings") -def test_get_project_folder(mock_get_global, mock_get_dir: MagicMock): +@patch("rascal2.ui.presenter.RATRunner", autospec=True, return_value=MagicMock()) +def test_get_project_folder(mock_runner, mock_get_global, mock_get_dir: MagicMock): """Test that getting a specified folder works as expected.""" with tempfile.TemporaryDirectory() as tmp_dir: ini_file = Path(tmp_dir) / "settings.ini" @@ -206,7 +208,8 @@ def test_help_menu_actions_present(test_view, submenu_name, action_names_and_lay assert action.text() == name -def test_toggle_slider(): +@patch("rascal2.ui.presenter.RATRunner", autospec=True, return_value=MagicMock()) +def test_toggle_slider(mock_runner): mw = MainWindowView() with patch.object(mw, "project_widget") as project_mock: show_text = mw.toggle_slider_action.property("show_text") diff --git a/tests/widgets/project/test_slider_view.py b/tests/widgets/project/test_slider_view.py index cb7270c3..4bc11c59 100644 --- a/tests/widgets/project/test_slider_view.py +++ b/tests/widgets/project/test_slider_view.py @@ -52,7 +52,8 @@ def draft_project(): return draft -def test_no_sliders_creation(): +@patch("rascal2.ui.presenter.RATRunner", autospec=True, return_value=MagicMock()) +def test_no_sliders_creation(mock_runner): """Slider view should show warning when there is no fitted parameter.""" mw = MainWindowView() draft = create_draft_project(ratapi.Project()) @@ -64,7 +65,8 @@ def test_no_sliders_creation(): assert label.text().startswith("There are no fitted parameters") -def test_sliders_creation(draft_project): +@patch("rascal2.ui.presenter.RATRunner", autospec=True, return_value=MagicMock()) +def test_sliders_creation(mock_runner, draft_project): """Sliders should be created for fitted parameter only.""" mw = MainWindowView() slider_view = SliderViewWidget(draft_project, mw) @@ -81,7 +83,8 @@ def test_sliders_creation(draft_project): assert draft_project["parameters"][0].name not in slider_view._sliders -def test_accept_and_cancel_slider_buttons(): +@patch("rascal2.ui.presenter.RATRunner", autospec=True, return_value=MagicMock()) +def test_accept_and_cancel_slider_buttons(mock_runner): mw = MainWindowView() draft = create_draft_project(ratapi.Project()) mw.toggle_sliders = MagicMock()