From ad67473c1ad3d135bcc68a38f8f5f666683d9825 Mon Sep 17 00:00:00 2001 From: Stephen Nneji Date: Wed, 1 Jul 2026 10:42:14 +0100 Subject: [PATCH 1/3] add validation --- rascal2/dialogs/startup_dialog.py | 17 +++++- rascal2/ui/model.py | 88 +++++++++++++++++++++++++++++++ rascal2/ui/presenter.py | 12 ++++- tests/ui/test_model.py | 22 +++++++- 4 files changed, 135 insertions(+), 4 deletions(-) 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..ea483fdf 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 @@ -39,6 +40,93 @@ 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}).", + 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}).", + stacklevel=1, + ) + + for i in range(num_of_contrasts): + 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}.", + stacklevel=1, + ) + for j in range(num_of_domains): + 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.", 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.", 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}).", + stacklevel=1, + ) + + for i in range(num_of_contrasts): + 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}.", + stacklevel=1, + ) + + for j in range(num_of_domains): + 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.", + 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.", + 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..49617397 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 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,19 @@ 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 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..2ed16c45 100644 --- a/tests/ui/test_model.py +++ b/tests/ui/test_model.py @@ -1,6 +1,7 @@ from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch +import warnings import numpy as np import pytest @@ -8,10 +9,13 @@ 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(): empty_results = Results( @@ -77,6 +81,22 @@ def test_save_project(empty_results, model): assert '"fitParams": []' in results +@pytest.mark.parametrize( + "result_file", + ( + "results_normal_calculate.json", + "results_domains_dream.json", + "results_domains_ns.json", + ), +) +def test_validate_plot_data(result_file): + 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) == 0 + + def test_save_project_as_script(model): model.project = Project(calculation="domains", name="test project") with TemporaryDirectory() as tmpdir: From cff3c7d86e663527706f498bf1dc2642ac3b76c8 Mon Sep 17 00:00:00 2001 From: Stephen Nneji Date: Wed, 1 Jul 2026 13:50:02 +0100 Subject: [PATCH 2/3] Adds unit test --- rascal2/ui/model.py | 32 +++++++++++++++++++------------- rascal2/ui/presenter.py | 4 ++-- tests/ui/test_model.py | 30 +++++++++++++++++++++++++----- 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/rascal2/ui/model.py b/rascal2/ui/model.py index ea483fdf..3d458ab1 100644 --- a/rascal2/ui/model.py +++ b/rascal2/ui/model.py @@ -12,6 +12,10 @@ from rascal2.paths import EXAMPLES_PATH, EXAMPLES_TEMP_PATH +class InvalidResultWarning(Warning): + pass + + def copy_example_project(load_path): """Copy example project to temp directory so user does not modify original. @@ -61,7 +65,7 @@ def validate_plot_data(project, results): 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}).", - stacklevel=1, + InvalidResultWarning, stacklevel=1, ) for attr in ["reflectivity", "shiftedData", "sldProfiles", "resampledLayers"]: @@ -71,25 +75,27 @@ def validate_plot_data(project, results): 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}).", - stacklevel=1, + InvalidResultWarning, stacklevel=1, ) - for i in range(num_of_contrasts): + 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}.", - stacklevel=1, + InvalidResultWarning, stacklevel=1, ) - for j in range(num_of_domains): + 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.", stacklevel=1 + 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.", stacklevel=1) + 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"]: @@ -100,30 +106,30 @@ def validate_plot_data(project, results): 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}).", - stacklevel=1, + InvalidResultWarning, stacklevel=1, ) - for i in range(num_of_contrasts): + 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}.", - stacklevel=1, + InvalidResultWarning, stacklevel=1, ) - for j in range(num_of_domains): + 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.", - stacklevel=1, + 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.", - stacklevel=1, + InvalidResultWarning, stacklevel=1, ) diff --git a/rascal2/ui/presenter.py b/rascal2/ui/presenter.py index 49617397..b7d24359 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, validate_plot_data +from .model import MainWindowModel, validate_plot_data, InvalidResultWarning class MainWindowPresenter: @@ -59,7 +59,7 @@ def load_project(self, load_path: str): validate_plot_data(self.model.project, self.model.results) if records: self.model.results = None - warning = [str(record.message) for record in records] + 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) diff --git a/tests/ui/test_model.py b/tests/ui/test_model.py index 2ed16c45..1ba46afe 100644 --- a/tests/ui/test_model.py +++ b/tests/ui/test_model.py @@ -82,18 +82,38 @@ def test_save_project(empty_results, model): @pytest.mark.parametrize( - "result_file", + ("result_file", "warning_count"), ( - "results_normal_calculate.json", - "results_domains_dream.json", - "results_domains_ns.json", + ("results_normal_calculate.json", 5), + ("results_domains_dream.json", 10), + ("results_domains_ns.json", 10), ), ) -def test_validate_plot_data(result_file): +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 From d569db08a48ba156a58da95d7cc556a18678c6bc Mon Sep 17 00:00:00 2001 From: Stephen Nneji Date: Mon, 6 Jul 2026 15:21:35 +0100 Subject: [PATCH 3/3] ruff --- rascal2/ui/model.py | 33 ++++++++++++++++++++++----------- rascal2/ui/presenter.py | 6 ++++-- tests/ui/test_model.py | 15 +++++++-------- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/rascal2/ui/model.py b/rascal2/ui/model.py index 3d458ab1..2d6a5a78 100644 --- a/rascal2/ui/model.py +++ b/rascal2/ui/model.py @@ -13,7 +13,7 @@ class InvalidResultWarning(Warning): - pass + """Warning for invalid calculation results.""" def copy_example_project(load_path): @@ -65,7 +65,8 @@ def validate_plot_data(project, results): 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, + InvalidResultWarning, + stacklevel=1, ) for attr in ["reflectivity", "shiftedData", "sldProfiles", "resampledLayers"]: @@ -75,7 +76,8 @@ def validate_plot_data(project, results): 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, + InvalidResultWarning, + stacklevel=1, ) for i in range(num_rows): @@ -84,18 +86,23 @@ def validate_plot_data(project, results): 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, + 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 + 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) + 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"]: @@ -106,7 +113,8 @@ def validate_plot_data(project, results): 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, + InvalidResultWarning, + stacklevel=1, ) for i in range(num_rows): @@ -115,7 +123,8 @@ def validate_plot_data(project, results): 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, + InvalidResultWarning, + stacklevel=1, ) for j in range(len(entry[i])): @@ -123,13 +132,15 @@ def validate_plot_data(project, results): warnings.warn( f"The predictionIntervals.{attr} entry (row {i}, column {j}) " "in results has incorrect dimensions.", - InvalidResultWarning, stacklevel=1, + 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, + InvalidResultWarning, + stacklevel=1, ) diff --git a/rascal2/ui/presenter.py b/rascal2/ui/presenter.py index b7d24359..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, validate_plot_data, InvalidResultWarning +from .model import InvalidResultWarning, MainWindowModel, validate_plot_data class MainWindowPresenter: @@ -59,7 +59,9 @@ def load_project(self, load_path: str): 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)] + 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) diff --git a/tests/ui/test_model.py b/tests/ui/test_model.py index 1ba46afe..2547f74b 100644 --- a/tests/ui/test_model.py +++ b/tests/ui/test_model.py @@ -1,7 +1,7 @@ +import warnings from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch -import warnings import numpy as np import pytest @@ -12,7 +12,6 @@ from rascal2.ui.model import MainWindowModel, validate_plot_data from tests.utils import check_results_equal - DATA_PATH = Path(__file__, "../../data/").resolve() @@ -84,9 +83,9 @@ def test_save_project(empty_results, model): @pytest.mark.parametrize( ("result_file", "warning_count"), ( - ("results_normal_calculate.json", 5), - ("results_domains_dream.json", 10), - ("results_domains_ns.json", 10), + ("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): @@ -100,9 +99,9 @@ def test_validate_plot_data_throws_warnings(result_file, 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), + ("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):