diff --git a/rascal2/dialogs/startup_dialog.py b/rascal2/dialogs/startup_dialog.py index 832df8e2..b6155129 100644 --- a/rascal2/dialogs/startup_dialog.py +++ b/rascal2/dialogs/startup_dialog.py @@ -173,12 +173,27 @@ def reject(self): if self.parent().centralWidget() is self.parent().startup_dlg: self.parent().startup_dlg.setVisible(True) - def project_start_success(self): + def project_start_success(self, warning): self.parent().presenter.initialise_ui() + if not self.parent().toolbar.isEnabled(): self.parent().toolbar.setEnabled(True) self.accept() + if warning is not None: + message = ( + "The result JSON was not loaded because it is invalid. " + "The other project files were loaded successfully. " + ) + + message_box = QtWidgets.QMessageBox(self) + message_box.setStyleSheet("QMessageBox QTextEdit{color: red; font-family: monospace; font-weight:500;}") + message_box.setWindowTitle(self.windowTitle()) + message_box.setIcon(QtWidgets.QMessageBox.Icon.Warning) + message_box.setText(message) + message_box.setDetailedText("\n".join(warning)) + message_box.exec() + def project_start_failed(self, exception, args): folder_name = args[0] error = str(exception).strip().replace("\n", "") diff --git a/rascal2/ui/model.py b/rascal2/ui/model.py index 4612dbb8..2d6a5a78 100644 --- a/rascal2/ui/model.py +++ b/rascal2/ui/model.py @@ -1,6 +1,7 @@ import os import shutil import sys +import warnings from json import JSONDecodeError from pathlib import Path @@ -11,6 +12,10 @@ from rascal2.paths import EXAMPLES_PATH, EXAMPLES_TEMP_PATH +class InvalidResultWarning(Warning): + """Warning for invalid calculation results.""" + + def copy_example_project(load_path): """Copy example project to temp directory so user does not modify original. @@ -39,6 +44,106 @@ def copy_example_project(load_path): return str(load_path) +def validate_plot_data(project, results): + """Validate plot data. + + Parameters + ---------- + project : ratapi.Project + The project + results : Union[ratapi.outputs.Results, ratapi.outputs.BayesResults] + The calculation results. + """ + if results is None: + return + + num_of_contrasts = len(project.contrasts) + num_of_domains = 1 if project.calculation == "normal" else 2 + + sub_rough_size = len(results.contrastParams.subRoughs) + if sub_rough_size != num_of_contrasts: + warnings.warn( + "The contrastParams.subRoughs entry in results has an incorrect size. " + f"The size ({sub_rough_size}) should be equal to the number of contrast ({num_of_contrasts}).", + InvalidResultWarning, + stacklevel=1, + ) + + for attr in ["reflectivity", "shiftedData", "sldProfiles", "resampledLayers"]: + entry = getattr(results, attr) + num_rows = len(entry) + if num_rows != num_of_contrasts: + warnings.warn( + f"The {attr} entry in results has an incorrect number of rows. " + f"The number of rows ({num_rows}) should be equal to the number of contrast ({num_of_contrasts}).", + InvalidResultWarning, + stacklevel=1, + ) + + for i in range(num_rows): + if isinstance(entry[i], list): + if len(entry[i]) != num_of_domains: + warnings.warn( + f"The {attr} entry in results has an incorrect number of columns. " + f"Row {i} has {len(entry[i])} columns instead of {num_of_domains}.", + InvalidResultWarning, + stacklevel=1, + ) + for j in range(len(entry[i])): + if len(entry[i][j].shape) != 2 or entry[i][j].shape[1] < 2: + warnings.warn( + f"The {attr} entry (row {i}, column {j}) in results has incorrect dimensions.", + InvalidResultWarning, + stacklevel=1, + ) + else: + if len(entry[i].shape) != 2 or entry[i].shape[1] < 2: + warnings.warn( + f"The {attr} entry (row {i}) in results has incorrect dimensions.", + InvalidResultWarning, + stacklevel=1, + ) + + if isinstance(results, ratapi.outputs.BayesResults): + for attr in ["reflectivity", "sld"]: + entry = getattr(results.predictionIntervals, attr) + dim_source = results.sldProfiles if attr == "sld" else results.reflectivity + num_rows = len(entry) + if num_rows != num_of_contrasts: + warnings.warn( + f"The predictionIntervals.{attr} entry in results has an incorrect number of rows. " + f"The number of rows ({num_rows}) should match the number of contrast ({num_of_contrasts}).", + InvalidResultWarning, + stacklevel=1, + ) + + for i in range(num_rows): + if isinstance(entry[i], list): + if len(entry[i]) != num_of_domains: + warnings.warn( + f"The predictionIntervals.{attr} entry in results has an incorrect number of columns. " + f"Row {i} has {len(entry[i])} columns instead of {num_of_domains}.", + InvalidResultWarning, + stacklevel=1, + ) + + for j in range(len(entry[i])): + if entry[i][j].shape != (5, dim_source[i][j].shape[0]): + warnings.warn( + f"The predictionIntervals.{attr} entry (row {i}, column {j}) " + "in results has incorrect dimensions.", + InvalidResultWarning, + stacklevel=1, + ) + else: + if entry[i].shape != (5, dim_source[i].shape[0]): + warnings.warn( + f"The predictionIntervals.{attr} entry (row {i}) in results has incorrect dimensions.", + InvalidResultWarning, + stacklevel=1, + ) + + class MainWindowModel(QtCore.QObject): """Manages project data and communicates to view via signals. diff --git a/rascal2/ui/presenter.py b/rascal2/ui/presenter.py index 0f9e80e6..d190f998 100644 --- a/rascal2/ui/presenter.py +++ b/rascal2/ui/presenter.py @@ -13,7 +13,7 @@ from rascal2.core.writer import write_result_to_zipped_csvs from rascal2.settings import update_recent_projects -from .model import MainWindowModel +from .model import InvalidResultWarning, MainWindowModel, validate_plot_data class MainWindowPresenter: @@ -45,7 +45,7 @@ def create_project(self, name: str, save_path: str): self.initialise_ui() def load_project(self, load_path: str): - """Load an existing RAT project then initialise UI. + """Load an existing RAT project. Parameters ---------- @@ -53,11 +53,21 @@ def load_project(self, load_path: str): The path from which to load the project. """ + warning = None self.model.load_project(load_path) + with warnings.catch_warnings(record=True) as records: + validate_plot_data(self.model.project, self.model.results) + if records: + self.model.results = None + warning = [ + str(record.message) for record in records if isinstance(record.message, InvalidResultWarning) + ] if self.model.results is None: self.model.results = self.quick_run() update_recent_projects(load_path) + return warning + def load_r1_project(self, load_path: str): """Load a RAT project from a RasCAL-1 project file. diff --git a/tests/ui/test_model.py b/tests/ui/test_model.py index dbf15839..2547f74b 100644 --- a/tests/ui/test_model.py +++ b/tests/ui/test_model.py @@ -1,3 +1,4 @@ +import warnings from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch @@ -8,9 +9,11 @@ from ratapi.outputs import CalculationResults, ContrastParams from ratapi.utils.enums import Calculations -from rascal2.ui.model import MainWindowModel +from rascal2.ui.model import MainWindowModel, validate_plot_data from tests.utils import check_results_equal +DATA_PATH = Path(__file__, "../../data/").resolve() + @pytest.fixture def empty_results(): @@ -77,6 +80,42 @@ def test_save_project(empty_results, model): assert '"fitParams": []' in results +@pytest.mark.parametrize( + ("result_file", "warning_count"), + ( + ("results_normal_calculate.json", 5), + ("results_domains_dream.json", 10), + ("results_domains_ns.json", 10), + ), +) +def test_validate_plot_data_throws_warnings(result_file, warning_count): + project = Project() + result = Results.load(DATA_PATH / result_file) + with warnings.catch_warnings(record=True) as records: + validate_plot_data(project, result) + assert len(records) == warning_count + + +@pytest.mark.parametrize( + ("result_file", "calc_type", "num_of_contrast"), + ( + ("results_normal_calculate.json", Calculations.Normal, 2), + ("results_domains_dream.json", Calculations.Domains, 1), + ("results_domains_ns.json", Calculations.Domains, 1), + ), +) +def test_validate_plot_data(result_file, calc_type, num_of_contrast): + project = Project(calculation=calc_type) + for i in range(num_of_contrast): + project.contrasts.append(name=f"Contrast {i}") + + result = Results.load(DATA_PATH / result_file) + with warnings.catch_warnings(record=True) as records: + validate_plot_data(project, result) + print([m.message for m in records]) + assert len(records) == 0 + + def test_save_project_as_script(model): model.project = Project(calculation="domains", name="test project") with TemporaryDirectory() as tmpdir: