Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds terminal widget and ability to run RAT #28

Merged
merged 23 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "rascal2"
version = "0.0.0"
dynamic = ["version"]
dependencies = [
"RATapi",
"PyQt6",
Expand All @@ -17,6 +17,9 @@ license = {file = "LICENSE"}
Documentation = "https://rascalsoftware.github.io/RAT/master/index.html"
Repository = "https://github.com/RascalSoftware/RasCAL-2"

[tool.setuptools.dynamic]
version = {attr = "rascal2.RASCAL2_VERSION"}

[tool.ruff]
line-length = 120

Expand Down
1 change: 1 addition & 0 deletions rascal2/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
RASCAL2_VERSION = "0.0.0"
alexhroom marked this conversation as resolved.
Show resolved Hide resolved
14 changes: 11 additions & 3 deletions rascal2/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def setup_settings(project_path: str | PathLike) -> Settings:
return Settings()


def setup_logging(log_path: str | PathLike, level: int = logging.INFO) -> logging.Logger:
def setup_logging(log_path: str | PathLike, terminal, level: int = logging.INFO) -> logging.Logger:
"""Set up logging for the project.

The default logging path and level are defined in the settings.
Expand All @@ -68,6 +68,8 @@ def setup_logging(log_path: str | PathLike, level: int = logging.INFO) -> loggin
----------
log_path : str | PathLike
The path to where the log file will be written.
terminal : TerminalWidget
The TerminalWidget instance which acts as an IO stream.
level : int, default logging.INFO
The debug level for the logger.

Expand All @@ -77,11 +79,17 @@ def setup_logging(log_path: str | PathLike, level: int = logging.INFO) -> loggin
logger.setLevel(level)
logger.handlers.clear()

# TODO add console print handler when console is added
# https://github.com/RascalSoftware/RasCAL-2/issues/5
log_filehandler = logging.FileHandler(path)
file_formatting = logging.Formatter("%(asctime)s - %(threadName)s - %(name)s - %(levelname)s - %(message)s")
log_filehandler.setFormatter(file_formatting)
logger.addHandler(log_filehandler)

# handler that logs to terminal widget
log_termhandler = logging.StreamHandler(stream=terminal)
term_formatting = logging.Formatter("%(levelname)s - %(message)s")
log_termhandler.setFormatter(term_formatting)
logger.addHandler(log_termhandler)

return logger


Expand Down
3 changes: 2 additions & 1 deletion rascal2/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from rascal2.core.runner import RATRunner
from rascal2.core.settings import Settings, get_global_settings

__all__ = ["Settings", "get_global_settings"]
__all__ = ["RATRunner", "get_global_settings", "Settings"]
107 changes: 107 additions & 0 deletions rascal2/core/runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""QObject for running RAT."""

from dataclasses import dataclass
from logging import INFO
from multiprocessing import Process, Queue

import RATapi as RAT
from PyQt6 import QtCore
from RATapi.utils.enums import Procedures


class RATRunner(QtCore.QObject):
"""Class for running RAT."""

event_received = QtCore.pyqtSignal()
finished = QtCore.pyqtSignal()
stopped = QtCore.pyqtSignal()

def __init__(self, rat_inputs, procedure: Procedures, display_on: bool):
super().__init__()
self.timer = QtCore.QTimer()
self.timer.setInterval(1)
self.timer.timeout.connect(self.check_queue)

# this queue handles both event data and results
self.queue = Queue()

self.process = Process(target=run, args=(self.queue, rat_inputs, procedure, display_on))

self.updated_problem = None
self.results = None
self.error = None
self.events = []

def start(self):
"""Start the calculation."""
self.process.start()
self.timer.start()

def interrupt(self):
"""Interrupt the running process."""
self.timer.stop()
self.process.kill()
self.stopped.emit()

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):
if isinstance(item, tuple):
self.updated_problem, self.results = item
self.finished.emit()
elif isinstance(item, Exception):
self.error = item
self.stopped.emit()
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):
"""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.

"""
problem_definition, cells, limits, priors, cpp_controls = rat_inputs

if display:
RAT.events.register(RAT.events.EventTypes.Message, queue.put)
RAT.events.register(RAT.events.EventTypes.Progress, queue.put)
queue.put(LogData(INFO, "Starting RAT"))

try:
problem_definition, output_results, bayes_results = RAT.rat_core.RATMain(
problem_definition, cells, limits, cpp_controls, priors
)
results = RAT.outputs.make_results(procedure, output_results, bayes_results)
except Exception as err:
queue.put(err)
return

if display:
queue.put(LogData(INFO, "Finished RAT"))
RAT.events.clear()

queue.put((problem_definition, results))
return


@dataclass
class LogData:
"""Dataclass for logging data."""

level: int
msg: str
7 changes: 6 additions & 1 deletion rascal2/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class SettingsGroups(StrEnum):

General = "General"
Logging = "Logging"
Terminal = "Terminal"
Windows = "Windows"


Expand Down Expand Up @@ -105,11 +106,15 @@ class Settings(BaseModel, validate_assignment=True, arbitrary_types_allowed=True
# The global settings are read and written via this object using `set_global_settings`.
style: Styles = Field(default=Styles.Light, title=SettingsGroups.General, description="Style")
editor_fontsize: int = Field(default=12, title=SettingsGroups.General, description="Editor Font Size", gt=0)
terminal_fontsize: int = Field(default=12, title=SettingsGroups.General, description="Terminal Font Size", gt=0)

log_path: str = Field(default="logs/rascal.log", title=SettingsGroups.Logging, description="Path to Log File")
log_level: LogLevels = Field(default=LogLevels.Info, title=SettingsGroups.Logging, description="Minimum Log Level")

clear_terminal: bool = Field(
default=True, title=SettingsGroups.Terminal, description="Clear Terminal when Run Starts"
)
terminal_fontsize: int = Field(default=12, title=SettingsGroups.Terminal, description="Terminal Font Size", gt=0)

mdi_defaults: MDIGeometries = Field(
default=None, title=SettingsGroups.Windows, description="Default Window Geometries"
)
Expand Down
16 changes: 16 additions & 0 deletions rascal2/ui/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,19 @@ def create_project(self, name: str, save_path: str):
self.project = RAT.Project(name=name)
self.controls = RAT.Controls()
self.save_path = save_path

def update_project(self, problem_definition: RAT.rat_core.ProblemDefinition):
alexhroom marked this conversation as resolved.
Show resolved Hide resolved
"""Update the project given a set of results."""
parameter_field = {
"parameters": "params",
"bulk_in": "bulkIn",
"bulk_out": "bulkOut",
"scalefactors": "scalefactors",
"domain_ratios": "domainRatio",
"background_parameters": "backgroundParams",
"resolution_parameters": "resolutionParams",
}

for class_list in RAT.project.parameter_class_lists:
for index, value in enumerate(getattr(problem_definition, parameter_field[class_list])):
getattr(self.project, class_list)[index].value = value
86 changes: 82 additions & 4 deletions rascal2/ui/presenter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import re
import warnings
from typing import Any

import RATapi as RAT

from rascal2.core import commands
from rascal2.core.runner import LogData, RATRunner

from .model import MainWindowModel

Expand Down Expand Up @@ -71,7 +75,81 @@ def edit_controls(self, setting: str, value: Any):
return True

def interrupt_terminal(self):
"""Sends an interrupt signal to the terminal."""
# TODO: stub for when issue #9 is resolved
# https://github.com/RascalSoftware/RasCAL-2/issues/9
pass
"""Sends an interrupt signal to the RAT runner."""
self.runner.interrupt()

def run(self):
"""Run RAT."""
# reset terminal
self.view.terminal_widget.progress_bar.setVisible(False)
if self.view.settings.clear_terminal:
self.view.terminal_widget.clear()

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.start()

def handle_results(self):
"""Handle a RAT run being finished."""
self.model.update_project(self.runner.updated_problem)
self.view.handle_results(self.runner.results)

def handle_interrupt(self):
"""Handle a RAT run being interrupted."""
if self.runner.error is None:
self.view.logging.info("RAT run interrupted!")
else:
self.view.logging.error(f"RAT run failed with exception:\n{self.runner.error}")
self.view.reset_widgets()

def handle_event(self):
"""Handle event data produced by the RAT run."""
event = self.runner.events.pop(0)
if isinstance(event, str):
self.view.terminal_widget.write(event)
chi_squared = get_live_chi_squared(event, str(self.model.controls.procedure))
if chi_squared is not None:
self.view.controls_widget.chi_squared.setText(chi_squared)
elif isinstance(event, RAT.events.ProgressEventData):
self.view.terminal_widget.update_progress(event)
elif isinstance(event, LogData):
self.view.logging.log(event.level, event.msg)


# '\d+\.\d+' is the regex for
# 'some integer, then a decimal point, then another integer'
# the parentheses () mean it is put in capture group 1,
# which is what we return as the chi-squared value
# we compile these regexes on import to make `get_live_chi_squared` basically instant
chi_squared_patterns = {
"simplex": re.compile(r"(\d+\.\d+)"),
"de": re.compile(r"Best: (\d+\.\d+)"),
}


def get_live_chi_squared(item: str, procedure: str) -> str | None:
"""Get the chi-squared value from iteration message data.

Parameters
----------
item : str
The iteration message.
procedure : str
The procedure currently running.

Returns
-------
str or None
The chi-squared value from that procedure's message data in string form,
or None if one has not been found.

"""
if procedure not in chi_squared_patterns:
return None
# match returns None if no match found, so whether one is found can be checked via 'if match'
return match.group(1) if (match := chi_squared_patterns[procedure].search(item)) else None
27 changes: 22 additions & 5 deletions rascal2/ui/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from rascal2.core.settings import MDIGeometries, Settings
from rascal2.dialogs.project_dialog import ProjectDialog
from rascal2.dialogs.settings_dialog import SettingsDialog
from rascal2.widgets import ControlsWidget
from rascal2.widgets import ControlsWidget, TerminalWidget
from rascal2.widgets.startup_widget import StartUpWidget

from .presenter import MainWindowPresenter
Expand Down Expand Up @@ -37,7 +37,7 @@ def __init__(self):
# https://github.com/RascalSoftware/RasCAL-2/issues/7
# project: NO ISSUE YET
self.plotting_widget = QtWidgets.QWidget()
self.terminal_widget = QtWidgets.QWidget()
self.terminal_widget = TerminalWidget(self)
self.controls_widget = ControlsWidget(self)
self.project_widget = QtWidgets.QWidget()

Expand Down Expand Up @@ -163,6 +163,11 @@ def create_actions(self):
self.save_default_windows_action.setEnabled(False)
self.disabled_elements.append(self.save_default_windows_action)

# Terminal menu actions
self.clear_terminal_action = QtGui.QAction("Clear Terminal", self)
self.clear_terminal_action.setStatusTip("Clear text in the terminal")
self.clear_terminal_action.triggered.connect(self.terminal_widget.clear)

def create_menus(self):
"""Creates the main menu and sub menus"""
self.main_menu = self.menuBar()
Expand All @@ -189,8 +194,11 @@ def create_menus(self):
self.windows_menu.setEnabled(False)
self.disabled_elements.append(self.windows_menu)

self.help_menu = self.main_menu.addMenu("&Help")
self.help_menu.addAction(self.open_help_action)
terminal_menu = self.main_menu.addMenu("&Terminal")
terminal_menu.addAction(self.clear_terminal_action)

help_menu = self.main_menu.addMenu("&Help")
help_menu.addAction(self.open_help_action)

def open_docs(self):
"""Opens the documentation"""
Expand Down Expand Up @@ -283,10 +291,19 @@ def init_settings_and_log(self, save_path: str):
log_path = proj_path / log_path

log_path.parents[0].mkdir(parents=True, exist_ok=True)
self.logging = setup_logging(log_path, level=self.settings.log_level)
self.logging = setup_logging(log_path, self.terminal_widget, level=self.settings.log_level)

def enable_elements(self):
"""Enable the elements that are disabled on startup."""
for element in self.disabled_elements:
element.setEnabled(True)
self.disabled_elements = []

def handle_results(self, results):
"""Handle the results of a RAT run."""
self.reset_widgets()
self.controls_widget.chi_squared.setText(f"{results.calculationResults.sumChi:.6g}")

def reset_widgets(self):
"""Reset widgets after a run."""
alexhroom marked this conversation as resolved.
Show resolved Hide resolved
self.controls_widget.run_button.setChecked(False)
3 changes: 2 additions & 1 deletion rascal2/widgets/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from rascal2.widgets.controls import ControlsWidget
from rascal2.widgets.inputs import AdaptiveDoubleSpinBox, ValidatedInputWidget
from rascal2.widgets.terminal import TerminalWidget

__all__ = ["ControlsWidget", "ValidatedInputWidget", "AdaptiveDoubleSpinBox"]
__all__ = ["ControlsWidget", "TerminalWidget", "ValidatedInputWidget", "AdaptiveDoubleSpinBox"]
Loading
Loading