diff --git a/EasyReflectometryApp/Backends/Mock/Plotting.qml b/EasyReflectometryApp/Backends/Mock/Plotting.qml index 57e49717..9b55c586 100644 --- a/EasyReflectometryApp/Backends/Mock/Plotting.qml +++ b/EasyReflectometryApp/Backends/Mock/Plotting.qml @@ -21,6 +21,11 @@ QtObject { property double analysisMinY: -40. property double analysisMaxY: 40. + property double residualMinX: 0.01 + property double residualMaxX: 0.30 + property double residualMinY: -0.1 + property double residualMaxY: 0.1 + property int modelCount: 1 // Plot mode properties @@ -133,4 +138,13 @@ QtObject { ] } + function getResidualDataPoints(index) { + console.debug(`getResidualDataPoints ${index}`) + return [ + { 'x': 0.01, 'y': 0.002 }, + { 'x': 0.15, 'y': -0.001 }, + { 'x': 0.30, 'y': 0.003 } + ] + } + } diff --git a/EasyReflectometryApp/Backends/Py/experiment.py b/EasyReflectometryApp/Backends/Py/experiment.py index 4d75d52c..73bd562e 100644 --- a/EasyReflectometryApp/Backends/Py/experiment.py +++ b/EasyReflectometryApp/Backends/Py/experiment.py @@ -12,6 +12,7 @@ class Experiment(QObject): experimentChanged = Signal() externalExperimentChanged = Signal() + qRangeUpdated = Signal() def __init__(self, project_lib: ProjectLib, parent=None): super().__init__(parent) @@ -59,12 +60,16 @@ def load(self, paths: str) -> None: if isinstance(paths, str): paths = paths.split(',') + q_range_changed = False for path in paths: generalized = IO.generalizePath(path) if self._project_logic.count_datasets_in_file(generalized) > 1: - self._project_logic.load_all_experiments_from_file(generalized) + _count, changed = self._project_logic.load_all_experiments_from_file(generalized) else: - self._project_logic.load_new_experiment(generalized) + changed = self._project_logic.load_new_experiment(generalized) + if changed: + q_range_changed = True self.experimentChanged.emit() self.externalExperimentChanged.emit() - pass # debug anchor + if q_range_changed: + self.qRangeUpdated.emit() diff --git a/EasyReflectometryApp/Backends/Py/logic/calculators.py b/EasyReflectometryApp/Backends/Py/logic/calculators.py index 5c5d49c9..c8ed4002 100644 --- a/EasyReflectometryApp/Backends/Py/logic/calculators.py +++ b/EasyReflectometryApp/Backends/Py/logic/calculators.py @@ -17,6 +17,6 @@ def set_current_index(self, new_value: int) -> None: if new_value != self._current_index: self._current_index = new_value new_calculator = self._list_available_calculators[new_value] - self._project_lib._calculator.switch(new_calculator) + self._project_lib.calculator = new_calculator return True return False diff --git a/EasyReflectometryApp/Backends/Py/logic/fitting.py b/EasyReflectometryApp/Backends/Py/logic/fitting.py index ad18c8aa..6170d2cd 100644 --- a/EasyReflectometryApp/Backends/Py/logic/fitting.py +++ b/EasyReflectometryApp/Backends/Py/logic/fitting.py @@ -224,7 +224,7 @@ def on_fit_finished(self, results: List[FitResults]) -> None: @property def fit_n_pars(self) -> int: - """Return total number of refined parameters across all fits.""" + """Return the global number of refined parameters for the fit.""" if self._results: return sum(r.n_pars for r in self._results) if self._result is None: @@ -233,16 +233,22 @@ def fit_n_pars(self) -> int: @property def fit_chi2(self) -> float: - """Return total chi-squared across all fits.""" + """Return reduced chi-squared across all fits (chi2 / degrees of freedom)""" if self._results: try: - return float(sum(r.chi2 for r in self._results)) + total_chi2 = float(sum(r.chi2 for r in self._results)) + total_points = sum(len(r.x) for r in self._results) + n_params = self._results[0].n_pars + total_dof = total_points - n_params + if total_dof <= 0: + return 0.0 + return total_chi2 / total_dof except (ValueError, TypeError): return 0.0 if self._result is None: return 0.0 try: - return float(self._result.chi2) + return float(self._result.reduced_chi) except (ValueError, TypeError): return 0.0 diff --git a/EasyReflectometryApp/Backends/Py/logic/helpers.py b/EasyReflectometryApp/Backends/Py/logic/helpers.py index 70465a5b..630a3278 100644 --- a/EasyReflectometryApp/Backends/Py/logic/helpers.py +++ b/EasyReflectometryApp/Backends/Py/logic/helpers.py @@ -1,6 +1,6 @@ -# 5SPDX-FileCopyrightText: 2025 EasyApp contributors +# 5SPDX-FileCopyrightText: 2026 EasyApp contributors # SPDX-License-Identifier: BSD-3-Clause -# © 2025 Contributors to the EasyApp project +# © 2026 Contributors to the EasyApp project class IO: diff --git a/EasyReflectometryApp/Backends/Py/logic/models.py b/EasyReflectometryApp/Backends/Py/logic/models.py index 37bc1e81..44349cf7 100644 --- a/EasyReflectometryApp/Backends/Py/logic/models.py +++ b/EasyReflectometryApp/Backends/Py/logic/models.py @@ -11,7 +11,10 @@ class Models: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib - self._models = project_lib._models + + @property + def _models(self) -> ModelCollection: + return self._project_lib._models @property def index(self) -> int: diff --git a/EasyReflectometryApp/Backends/Py/logic/parameters.py b/EasyReflectometryApp/Backends/Py/logic/parameters.py index 51386f8b..37ca5428 100644 --- a/EasyReflectometryApp/Backends/Py/logic/parameters.py +++ b/EasyReflectometryApp/Backends/Py/logic/parameters.py @@ -258,11 +258,22 @@ def _make_alias(name: str) -> str: return alias def _get_parameter_display_data(param: Parameter, model_unique_name: str) -> Tuple[str, str]: - """Extract display name and group from parameter path.""" + """Extract display name and group from parameter path. + + For layer parameters (thickness, roughness), uses the assembly name + from the path instead of the layer name, so that renaming an assembly + in the Model Editor is reflected in the Analysis parameters table. + """ path = global_object.map.find_path(model_unique_name, param.unique_name) if len(path) >= 2: - parent_name = global_object.map.get_item_by_key(path[-2]).name param_name = global_object.map.get_item_by_key(path[-1]).name + # For layer parameters the path is: + # Model -> Sample -> Assembly -> LayerCollection -> Layer -> param + # Use the assembly name (path[-4]) instead of the layer name (path[-2]) + if _is_layer_parameter(param) and len(path) >= 4: + parent_name = global_object.map.get_item_by_key(path[-4]).name + else: + parent_name = global_object.map.get_item_by_key(path[-2]).name return f'{parent_name} {param_name}', parent_name return param.name, '' # Fallback to parameter name without group diff --git a/EasyReflectometryApp/Backends/Py/logic/project.py b/EasyReflectometryApp/Backends/Py/logic/project.py index d13674c2..c5b8b92c 100644 --- a/EasyReflectometryApp/Backends/Py/logic/project.py +++ b/EasyReflectometryApp/Backends/Py/logic/project.py @@ -1,6 +1,7 @@ from copy import copy from pathlib import Path +import numpy as np from easyreflectometry import Project as ProjectLib @@ -107,17 +108,61 @@ def save(self) -> None: def load(self, path: str) -> None: self._project_lib.load_from_json(path) - def load_experiment(self, path: str) -> None: + def load_experiment(self, path: str) -> bool: self._project_lib.load_experiment_for_model_at_index(path, self._project_lib._current_model_index) + return self._sync_q_max_with_loaded_experiments() - def load_new_experiment(self, path: str) -> None: + def load_new_experiment(self, path: str) -> bool: self._project_lib.load_new_experiment(path) + return self._sync_q_max_with_loaded_experiments() def count_datasets_in_file(self, path: str) -> int: return self._project_lib.count_datasets_in_file(path) - def load_all_experiments_from_file(self, path: str) -> int: - return self._project_lib.load_all_experiments_from_file(path) + def load_all_experiments_from_file(self, path: str) -> tuple[int, bool]: + loaded_count = self._project_lib.load_all_experiments_from_file(path) + q_max_changed = self._sync_q_max_with_loaded_experiments() + return loaded_count, q_max_changed + + def _sync_q_max_with_loaded_experiments(self) -> bool: + """Set model q_max to the largest q value found in loaded experiments. + + :return: True if q_max was changed, False otherwise. + :rtype: bool + """ + experiments = self._project_lib._experiments + if not experiments: + return False + + if hasattr(experiments, 'values'): + experiment_iterable = experiments.values() + else: + experiment_iterable = experiments + + q_max_candidates = [] + for experiment in experiment_iterable: + x_values = getattr(experiment, 'x', None) + if x_values is None: + continue + + q_values = np.asarray(x_values, dtype=float) + if q_values.size == 0: + continue + + finite_q_values = q_values[np.isfinite(q_values)] + if finite_q_values.size == 0: + continue + + q_max_candidates.append(float(np.max(finite_q_values))) + + if not q_max_candidates: + return False + + new_q_max = max(q_max_candidates) + if new_q_max != self._project_lib.q_max: + self._project_lib.q_max = new_q_max + return True + return False def set_sample_from_orso(self, sample) -> None: self._project_lib.set_sample_from_orso(sample) diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index a34744b6..81730e1b 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -42,6 +42,7 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._sld_x_reversed = False self._scale_shown = False self._bkg_shown = False + self._residual_range_cache = None self._chartRefs = { 'QtCharts': { 'samplePage': { @@ -65,6 +66,7 @@ def reset_data(self): self._sample_data = {} self._model_data = {} self._sld_data = {} + self._residual_range_cache = None console.debug(IO.formatMsg('sub', 'Sample and SLD data cleared')) def _apply_rq4(self, x, y): @@ -76,6 +78,31 @@ def _apply_rq4(self, x, y): return y * (x**4) return y + def _qtcharts_series_ref(self, page: str, serie: str): + return self._chartRefs['QtCharts'].get(page, {}).get(serie) + + def _clear_qtcharts_series(self, page: str, *series_names: str) -> bool: + missing_series = [] + for series_name in series_names: + series_ref = self._qtcharts_series_ref(page, series_name) + if series_ref is None: + missing_series.append(series_name) + continue + series_ref.clear() + + if missing_series: + console.debug( + IO.formatMsg( + 'sub', + f'{page} series unavailable', + ', '.join(missing_series), + 'skipping redraw', + ) + ) + return False + + return True + # R(q)×q⁴ mode @Property(bool, notify=plotModeChanged) def plotRQ4(self) -> bool: @@ -428,6 +455,82 @@ def experimentMinY(self): return -10.0 return np.log10(valid_y.min()) + # Residual ranges + def _invalidate_residual_range_cache(self): + """Clear the cached residual range so it is recomputed on next access.""" + self._residual_range_cache = None + + def _get_residual_range(self) -> tuple: + """Return (min_x, max_x, min_y, max_y) for the residual chart. + + X range matches the filtered analysis domain. Y range is computed + from residual values across all currently selected experiments, with + a 10 % margin. Safe fallback values are returned when data is empty. + + The result is cached until invalidated by ``_invalidate_residual_range_cache``. + """ + if self._residual_range_cache is not None: + return self._residual_range_cache + + min_x, max_x = float('inf'), float('-inf') + min_y, max_y = float('inf'), float('-inf') + + try: + indices = [] + if self.is_multi_experiment_mode: + indices = list(self._proxy._analysis._selected_experiment_indices) + else: + indices = [self._project_lib.current_experiment_index] + + for exp_idx in indices: + try: + aligned = self._get_aligned_analysis_values(exp_idx) + for item in aligned: + q = item['q'] + calc = item['calculated'] + meas = item['measured'] + sigma = item['sigma'] + if sigma > 0.0: + residual = (calc - meas) / sigma + elif meas > 0.0: + residual = (calc - meas) / meas + else: + residual = calc - meas + min_x = min(min_x, q) + max_x = max(max_x, q) + min_y = min(min_y, residual) + max_y = max(max_y, residual) + except Exception as e: + console.debug(f'Residual range error for experiment {exp_idx}: {e}') + continue + except Exception as e: + console.debug(f'Error computing residual range: {e}') + + if min_x == float('inf'): + result = (0.0, 1.0, -1.0, 1.0) + else: + y_margin = max(abs(min_y), abs(max_y)) * 0.10 or 0.1 + result = (min_x, max_x, min_y - y_margin, max_y + y_margin) + + self._residual_range_cache = result + return result + + @Property(float, notify=sampleChartRangesChanged) + def residualMinX(self) -> float: + return self._get_residual_range()[0] + + @Property(float, notify=sampleChartRangesChanged) + def residualMaxX(self) -> float: + return self._get_residual_range()[1] + + @Property(float, notify=sampleChartRangesChanged) + def residualMinY(self) -> float: + return self._get_residual_range()[2] + + @Property(float, notify=sampleChartRangesChanged) + def residualMaxY(self) -> float: + return self._get_residual_range()[3] + @Property('QVariant', notify=chartRefsChanged) def chartRefs(self): return self._chartRefs @@ -537,68 +640,110 @@ def getExperimentDataPoints(self, experiment_index: int) -> list: console.debug(f'Error getting experiment data points for index {experiment_index}: {e}') return [] + def _get_aligned_analysis_values(self, experiment_index: int) -> list: + """Return aligned measured/calculated pairs in linear (rq4-transformed) space. + + Both values have ``_apply_rq4`` applied but no log10. Only points within + [q_min, q_max] are included. The caller is responsible for any further + transformation (log10 for display, subtraction for residuals, etc.). + + Each element is a dict:: + + {'q': float, 'measured': float, 'calculated': float} + """ + # Get measured experimental data + exp_data = self._project_lib.experimental_data_for_model_at_index(experiment_index) + + # Resolve model index, which may differ from experiment_index when multiple + # experiments share the same model. + model_index = 0 + model_found = False + if hasattr(exp_data, 'model') and exp_data.model is not None: + for idx, model in enumerate(self._project_lib.models): + if model is exp_data.model: + model_index = idx + model_found = True + break + if not model_found: + console.debug(f'Warning: model for experiment {experiment_index} ' + f'not found in models collection, falling back to model 0') + else: + model_index = experiment_index if experiment_index < len(self._project_lib.models) else 0 + + # Filter experimental q values to [q_min, q_max] + q_values = exp_data.x + mask = (q_values >= self._project_lib.q_min) & (q_values <= self._project_lib.q_max) + q_filtered = q_values[mask] + + # Evaluate model at the filtered experimental q points + calc_data = self._project_lib.model_data_for_model_at_index(model_index, q_filtered) + calc_y = calc_data.y + + if len(calc_y) != len(q_filtered): + console.debug(f'Warning: calculated data length ({len(calc_y)}) ' + f'differs from filtered experimental data ({len(q_filtered)}) ' + f'for experiment {experiment_index}') + + points = [] + calc_idx = 0 + for point in exp_data.data_points(): + q = point[0] + if self._project_lib.q_min <= q <= self._project_lib.q_max: + r_meas = point[1] + calc_y_val = calc_y[calc_idx] if calc_idx < len(calc_y) else r_meas + sigma_linear = float(np.sqrt(max(point[2], 0.0))) + sigma_transformed = float(self._apply_rq4(q, sigma_linear)) if sigma_linear > 0.0 else 0.0 + points.append({ + 'q': float(q), + 'measured': float(self._apply_rq4(q, r_meas)), + 'calculated': float(self._apply_rq4(q, calc_y_val)), + 'sigma': sigma_transformed, + }) + calc_idx += 1 + return points + @Slot(int, result='QVariantList') def getAnalysisDataPoints(self, experiment_index: int) -> list: """Get measured and calculated data points for a specific experiment for analysis plotting.""" try: - # Get measured experimental data - exp_data = self._project_lib.experimental_data_for_model_at_index(experiment_index) - - # Get the model index for this experiment - it may be different from experiment_index - # When multiple experiments share the same model - model_index = 0 - model_found = False - if hasattr(exp_data, 'model') and exp_data.model is not None: - # Find the model index in the models collection - for idx, model in enumerate(self._project_lib.models): - if model is exp_data.model: - model_index = idx - model_found = True - break - if not model_found: - console.debug(f'Warning: model for experiment {experiment_index} ' - f'not found in models collection, falling back to model 0') - else: - # Fallback: use experiment_index if it's within model range, else 0 - model_index = experiment_index if experiment_index < len(self._project_lib.models) else 0 - - # Get the q values from the experimental data for calculating the model - q_values = exp_data.x - # Filter to q range - mask = (q_values >= self._project_lib.q_min) & (q_values <= self._project_lib.q_max) - q_filtered = q_values[mask] + points = [] + for item in self._get_aligned_analysis_values(experiment_index): + q = item['q'] + r_meas = item['measured'] + r_calc = item['calculated'] + points.append({ + 'x': q, + 'measured': float(np.log10(r_meas)) if r_meas > 0 else -10.0, + 'calculated': float(np.log10(r_calc)) if r_calc > 0 else -10.0, + }) + return points + except Exception as e: + console.debug(f'Error getting analysis data points for index {experiment_index}: {e}') + return [] - # Get calculated model data at the same q points using the correct model index - calc_data = self._project_lib.model_data_for_model_at_index(model_index, q_filtered) + @Slot(int, result='QVariantList') + def getResidualDataPoints(self, experiment_index: int) -> list: + """Get normalized residual data points (model − experiment) / sigma. + Falls back to (model − experiment) / experiment when sigma is zero + (i.e. measurement uncertainty not provided). + """ + try: points = [] - exp_points = list(exp_data.data_points()) - calc_y = calc_data.y - - if len(calc_y) != len(q_filtered): - console.debug(f'Warning: calculated data length ({len(calc_y)}) ' - f'differs from filtered experimental data ({len(q_filtered)}) ' - f'for experiment {experiment_index}') - - calc_idx = 0 - for point in exp_points: - if point[0] < self._project_lib.q_max and self._project_lib.q_min < point[0]: - q = point[0] - r_meas = point[1] - calc_y_val = calc_y[calc_idx] if calc_idx < len(calc_y) else r_meas - r_meas = self._apply_rq4(q, r_meas) - calc_y_val = self._apply_rq4(q, calc_y_val) - points.append( - { - 'x': float(q), - 'measured': float(np.log10(r_meas)), - 'calculated': float(np.log10(calc_y_val)), - } - ) - calc_idx += 1 + for item in self._get_aligned_analysis_values(experiment_index): + calc = item['calculated'] + meas = item['measured'] + sigma = item['sigma'] + if sigma > 0.0: + residual = (calc - meas) / sigma + elif meas > 0.0: + residual = (calc - meas) / meas + else: + residual = calc - meas + points.append({'x': float(item['q']), 'y': float(residual)}) return points except Exception as e: - console.debug(f'Error getting analysis data points for index {experiment_index}: {e}') + console.debug(f'Error getting residual data points for index {experiment_index}: {e}') return [] def refreshSamplePage(self): @@ -616,7 +761,10 @@ def refreshExperimentPage(self): def refreshAnalysisPage(self): self._model_data = {} + self._invalidate_residual_range_cache() self.drawCalculatedAndMeasuredOnAnalysisChart() + # Notify the residual chart to re-poll data and ranges + self.sampleChartRangesChanged.emit() def refreshExperimentRanges(self): """Emit signal to update experiment chart ranges when selection changes.""" @@ -671,12 +819,12 @@ def drawMeasuredOnExperimentChart(self): self.qtchartsReplaceMeasuredOnExperimentChartAndRedraw() def qtchartsReplaceMeasuredOnExperimentChartAndRedraw(self): - series_measured = self._chartRefs['QtCharts']['experimentPage']['measuredSerie'] - series_measured.clear() - series_error_upper = self._chartRefs['QtCharts']['experimentPage']['errorUpperSerie'] - series_error_upper.clear() - series_error_lower = self._chartRefs['QtCharts']['experimentPage']['errorLowerSerie'] - series_error_lower.clear() + if not self._clear_qtcharts_series('experimentPage', 'measuredSerie', 'errorUpperSerie', 'errorLowerSerie'): + return + + series_measured = self._qtcharts_series_ref('experimentPage', 'measuredSerie') + series_error_upper = self._qtcharts_series_ref('experimentPage', 'errorUpperSerie') + series_error_lower = self._qtcharts_series_ref('experimentPage', 'errorLowerSerie') nr_points = 0 for point in self.experiment_data.data_points(): q = point[0] @@ -700,12 +848,7 @@ def qtchartsReplaceMultiExperimentChartAndRedraw(self): console.debug(IO.formatMsg('sub', 'Multi-experiment mode', 'drawing separate lines')) # Clear default series but don't use them for multi-experiment mode - if 'measuredSerie' in self._chartRefs['QtCharts']['experimentPage']: - self._chartRefs['QtCharts']['experimentPage']['measuredSerie'].clear() - if 'errorUpperSerie' in self._chartRefs['QtCharts']['experimentPage']: - self._chartRefs['QtCharts']['experimentPage']['errorUpperSerie'].clear() - if 'errorLowerSerie' in self._chartRefs['QtCharts']['experimentPage']: - self._chartRefs['QtCharts']['experimentPage']['errorLowerSerie'].clear() + self._clear_qtcharts_series('experimentPage', 'measuredSerie', 'errorUpperSerie', 'errorLowerSerie') # Individual experiment series are managed by QML # This method is called to trigger the refresh, actual drawing is handled by QML @@ -724,20 +867,18 @@ def qtchartsReplaceMultiExperimentAnalysisChartAndRedraw(self): console.debug(IO.formatMsg('sub', 'Multi-experiment mode', 'drawing separate lines on analysis page')) # Clear default series but don't use them for multi-experiment mode - if 'measuredSerie' in self._chartRefs['QtCharts']['analysisPage']: - self._chartRefs['QtCharts']['analysisPage']['measuredSerie'].clear() - if 'calculatedSerie' in self._chartRefs['QtCharts']['analysisPage']: - self._chartRefs['QtCharts']['analysisPage']['calculatedSerie'].clear() + self._clear_qtcharts_series('analysisPage', 'measuredSerie', 'calculatedSerie') # Individual experiment series are managed by QML # This method is called to trigger the refresh, actual drawing is handled by QML self.experimentDataChanged.emit() def qtchartsReplaceCalculatedAndMeasuredOnAnalysisChartAndRedraw(self): - series_measured = self._chartRefs['QtCharts']['analysisPage']['measuredSerie'] - series_measured.clear() - series_calculated = self._chartRefs['QtCharts']['analysisPage']['calculatedSerie'] - series_calculated.clear() + if not self._clear_qtcharts_series('analysisPage', 'measuredSerie', 'calculatedSerie'): + return + + series_measured = self._qtcharts_series_ref('analysisPage', 'measuredSerie') + series_calculated = self._qtcharts_series_ref('analysisPage', 'calculatedSerie') nr_points = 0 for point in self.experiment_data.data_points(): q = point[0] diff --git a/EasyReflectometryApp/Backends/Py/py_backend.py b/EasyReflectometryApp/Backends/Py/py_backend.py index 57ba5a8b..2712eadd 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -135,6 +135,11 @@ def plottingGetAnalysisDataPoints(self, experiment_index: int) -> list: """Get measured and calculated data points for a specific experiment for analysis plotting.""" return self._plotting_1d.getAnalysisDataPoints(experiment_index) + @Slot(int, result='QVariantList') + def plottingGetResidualDataPoints(self, experiment_index: int) -> list: + """Get residual data points (model - experiment) for a specific experiment.""" + return self._plotting_1d.getResidualDataPoints(experiment_index) + ######### Connections to relay info between the backend parts def _connect_backend_parts(self) -> None: self._connect_project_page() @@ -152,13 +157,15 @@ def _connect_project_page(self) -> None: def _connect_sample_page(self) -> None: self._sample.externalSampleChanged.connect(self._relay_sample_page_sample_changed) self._sample.externalRefreshPlot.connect(self._refresh_plots) - self._sample.modelsTableChanged.connect(self._analysis.parametersChanged) + self._sample.modelsTableChanged.connect(self._analysis._clearCacheAndEmitParametersChanged) + self._sample.modelsTableChanged.connect(self._analysis.experimentsChanged) # Connect sample changes to multi-experiment selection signal self._sample.modelsTableChanged.connect(self.multiExperimentSelectionChanged) def _connect_experiment_page(self) -> None: self._experiment.externalExperimentChanged.connect(self._relay_experiment_page_experiment_changed) self._experiment.externalExperimentChanged.connect(self._refresh_plots) + self._experiment.qRangeUpdated.connect(self._sample.qRangeChanged) def _connect_analysis_page(self) -> None: self._analysis.externalMinimizerChanged.connect(self._relay_analysis_page) @@ -168,6 +175,8 @@ def _connect_analysis_page(self) -> None: self._analysis.externalFittingChanged.connect(self._refresh_plots) self._analysis.externalExperimentChanged.connect(self._relay_experiment_page_experiment_changed) self._analysis.externalExperimentChanged.connect(self._refresh_plots) + # Update status bar when parameters change (e.g. fit checkbox toggle, post-fit) + self._analysis.parametersChanged.connect(self._status.statusChanged) # Connect multi-experiment selection changes self._analysis.experimentsChanged.connect(self.multiExperimentSelectionChanged) @@ -192,12 +201,10 @@ def _relay_project_page_project_changed(self): self._sample.assembliesIndexChanged.emit() self._experiment.experimentChanged.emit() self._analysis.experimentsChanged.emit() - self._analysis._clearCacheAndEmitParametersChanged() self._status.statusChanged.emit() self._summary.summaryChanged.emit() self._plotting_1d.reset_data() self._refresh_plots() - self._plotting_1d.samplePageResetAxes.emit() def _relay_sample_page_sample_changed(self): self._plotting_1d.reset_data() @@ -226,5 +233,6 @@ def _refresh_plots(self): self._plotting_1d.refreshSamplePage() self._plotting_1d.refreshExperimentPage() self._plotting_1d.refreshAnalysisPage() + self._plotting_1d.samplePageResetAxes.emit() # Emit signal for multi-experiment changes self.multiExperimentSelectionChanged.emit() diff --git a/EasyReflectometryApp/Backends/Py/status.py b/EasyReflectometryApp/Backends/Py/status.py index a02c9460..0809920a 100644 --- a/EasyReflectometryApp/Backends/Py/status.py +++ b/EasyReflectometryApp/Backends/Py/status.py @@ -1,6 +1,6 @@ -# 5SPDX-FileCopyrightText: 2025 EasyApp contributors +# 5SPDX-FileCopyrightText: 2026 EasyApp contributors # SPDX-License-Identifier: BSD-3-Clause -# © 2025 Contributors to the EasyApp project +# © 2026 Contributors to the EasyApp project from easyreflectometry import Project as ProjectLib from PySide6.QtCore import Property diff --git a/EasyReflectometryApp/Backends/Py/summary.py b/EasyReflectometryApp/Backends/Py/summary.py index ea83b164..c3c43049 100644 --- a/EasyReflectometryApp/Backends/Py/summary.py +++ b/EasyReflectometryApp/Backends/Py/summary.py @@ -1,6 +1,6 @@ -# 5SPDX-FileCopyrightText: 2025 EasyApp contributors +# 5SPDX-FileCopyrightText: 2026 EasyApp contributors # SPDX-License-Identifier: BSD-3-Clause -# © 2025 Contributors to the EasyApp project +# © 2026 Contributors to the EasyApp project from easyreflectometry import Project as ProjectLib from PySide6.QtCore import Property diff --git a/EasyReflectometryApp/Gui/Globals/ApplicationInfo.qml b/EasyReflectometryApp/Gui/Globals/ApplicationInfo.qml index 7c10fa07..38e3644e 100644 --- a/EasyReflectometryApp/Gui/Globals/ApplicationInfo.qml +++ b/EasyReflectometryApp/Gui/Globals/ApplicationInfo.qml @@ -10,7 +10,7 @@ QtObject { 'nameSuffixForLogo': 'reflectometry', 'icon': Qt.resolvedUrl('../Resources/Logo/App.svg'), 'developerYearsFrom': '2019', - 'developerYearsTo': '2025', + 'developerYearsTo': '2026', 'description': 'EasyReflectometry is a scientific software for \nmodelling and analysis of \nneutron and x-ray reflecometry data. \n\nEasyReflectometry is build by ESS DMSC in \nCopenhagen, Denmark.', 'developerIcons': [ { diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 1ab96e93..6c08039e 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -358,6 +358,12 @@ QtObject { readonly property var plottingAnalysisMaxX: activeBackend.plotting.sampleMaxX readonly property var plottingAnalysisMinY: activeBackend.plotting.sampleMinY readonly property var plottingAnalysisMaxY: activeBackend.plotting.sampleMaxY + + readonly property var plottingResidualMinX: activeBackend.plotting.residualMinX + readonly property var plottingResidualMaxX: activeBackend.plotting.residualMaxX + readonly property var plottingResidualMinY: activeBackend.plotting.residualMinY + readonly property var plottingResidualMaxY: activeBackend.plotting.residualMaxY + readonly property var calcSerieColor: activeBackend.plotting.calcSerieColor // Plot mode properties @@ -528,4 +534,12 @@ QtObject { return [] } } + function plottingGetResidualDataPoints(index) { + try { + return activeBackend.plottingGetResidualDataPoints(index) + } catch (e) { + console.warn("plottingGetResidualDataPoints failed:", e) + return [] + } + } } diff --git a/EasyReflectometryApp/Gui/Globals/Variables.qml b/EasyReflectometryApp/Gui/Globals/Variables.qml index 71b34dc1..7286bc58 100644 --- a/EasyReflectometryApp/Gui/Globals/Variables.qml +++ b/EasyReflectometryApp/Gui/Globals/Variables.qml @@ -11,6 +11,7 @@ QtObject { property bool showLegendOnSamplePage: false property bool showLegendOnExperimentPage: false property bool showLegendOnAnalysisPage: false + property bool showLegendOnAnalysisResidualsTab: false property bool useStaggeredPlotting: false property double staggeringFactor: 0.5 diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml index 0620e8ef..d3ad8769 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml @@ -8,7 +8,6 @@ import EasyApp.Gui.Elements as EaElements import EasyApp.Gui.Components as EaComponents import Gui.Globals as Globals -//import Gui.Components as Components EaComponents.ContentPage { @@ -37,6 +36,23 @@ EaComponents.ContentPage { Loader { source: 'Sidebar/Advanced/Layout.qml' } ] + footerComponent: Component { + EaElements.SideBarButton { + enabled: Globals.BackendWrapper.analysisExperimentsAvailable.length + wide: true + fontIcon: Globals.BackendWrapper.analysisFittingRunning ? 'stop-circle' : 'play-circle' + text: Globals.BackendWrapper.analysisFittingRunning ? qsTr('Cancel fitting') : qsTr('Start fitting') + + onClicked: { + console.debug(`Clicking '${text}' button: ${this}`) + Globals.BackendWrapper.analysisFittingStartStop() + } + + Component.onCompleted: Globals.References.pages.analysis.sidebar.basic.popups.startFittingButton = this + Loader { source: 'Sidebar/Basic/Popups/FitStatusDialog.qml' } + } + } + // continueButton.enabled: Globals.Proxies.main.summary.isCreated continueButton.onClicked: { diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index fec58f95..9011f5aa 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls @@ -424,6 +424,7 @@ Rectangle { Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + Globals.BackendWrapper.plottingRefreshAnalysis() // Initialize multi-experiment support updateMultiExperimentSeries() diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index 9eab767e..a150263c 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls @@ -95,7 +95,7 @@ Rectangle { repeat: false onTriggered: { analysisChartView.resetAxes() - sldChart.chartView.resetAxes() + lowerPanel.resetAllAxes() } } @@ -313,7 +313,7 @@ Rectangle { ToolTip.text: qsTr("Enable pan") onClicked: { analysisChartView.allowZoom = !analysisChartView.allowZoom - sldChart.chartView.allowZoom = analysisChartView.allowZoom + lowerPanel.setAllowZoom(analysisChartView.allowZoom) } } @@ -327,7 +327,7 @@ Rectangle { ToolTip.text: qsTr("Enable box zoom") onClicked: { analysisChartView.allowZoom = !analysisChartView.allowZoom - sldChart.chartView.allowZoom = analysisChartView.allowZoom + lowerPanel.setAllowZoom(analysisChartView.allowZoom) } } @@ -340,7 +340,7 @@ Rectangle { ToolTip.text: qsTr("Reset axes") onClicked: { analysisChartView.resetAxes() - sldChart.chartView.resetAxes() + lowerPanel.resetAllAxes() } } } @@ -445,6 +445,7 @@ Rectangle { Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + Globals.BackendWrapper.plottingRefreshAnalysis() // Initialize multi-experiment support updateMultiExperimentSeries() @@ -455,20 +456,13 @@ Rectangle { } } - // SLD Chart (1/3 height) - Gui.SldChart { - id: sldChart + // Lower panel: SLD tab + Residuals tab + SldView { + id: lowerPanel SplitView.fillHeight: true SplitView.preferredHeight: parent.height * 0.33 SplitView.minimumHeight: 80 - - showLegend: Globals.Variables.showLegendOnAnalysisPage - onShowLegendChanged: Globals.Variables.showLegendOnAnalysisPage = showLegend - - Component.onCompleted: { - Globals.References.pages.analysis.mainContent.sldView = sldChart.chartView - } } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml new file mode 100644 index 00000000..4cb5fa8d --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml @@ -0,0 +1,436 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtCharts + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Globals as EaGlobals +import EasyApp.Gui.Elements as EaElements + +import Gui.Globals as Globals + + +Rectangle { + id: root + + color: EaStyle.Colors.chartBackground + + property bool showLegend: false + + // Expose for external reset / zoom sync calls from CombinedView / SldView wrapper + readonly property alias chartView: chartView + + // Dynamically created per-experiment series in multi-experiment mode + property var residualSeries: [] + + ChartView { + id: chartView + + anchors.fill: parent + anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 + anchors.margins: -12 + + antialiasing: true + legend.visible: false + backgroundRoundness: 0 + backgroundColor: EaStyle.Colors.chartBackground + plotAreaColor: EaStyle.Colors.chartPlotAreaBackground + + property bool allowZoom: true + property bool allowHover: true + + property double xRange: Globals.BackendWrapper.plottingResidualMaxX - Globals.BackendWrapper.plottingResidualMinX + + ValueAxis { + id: axisX + titleText: "q (Å⁻¹)" + property double minAfterReset: Globals.BackendWrapper.plottingResidualMinX - chartView.xRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingResidualMaxX + chartView.xRange * 0.01 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + property double yRange: Globals.BackendWrapper.plottingResidualMaxY - Globals.BackendWrapper.plottingResidualMinY + + ValueAxis { + id: axisY + titleText: "(Model − Experiment) / σ" + property double minAfterReset: Globals.BackendWrapper.plottingResidualMinY - chartView.yRange * 0.05 + property double maxAfterReset: Globals.BackendWrapper.plottingResidualMaxY + chartView.yRange * 0.05 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + function resetAxes() { + axisX.min = axisX.minAfterReset + axisX.max = axisX.maxAfterReset + axisY.min = axisY.minAfterReset + axisY.max = axisY.maxAfterReset + } + + // Zero reference line — always visible, not affected by scale/bkg toggles + LineSeries { + id: zeroLine + axisX: axisX + axisY: axisY + useOpenGL: EaGlobals.Vars.useOpenGL + color: EaStyle.Colors.chartGridLine + width: 1 + style: Qt.DashLine + + Component.onCompleted: { + // Span the full residual X domain + zeroLine.append(Globals.BackendWrapper.plottingResidualMinX, 0) + zeroLine.append(Globals.BackendWrapper.plottingResidualMaxX, 0) + } + } + + // Single-experiment residual series (hidden in multi-experiment mode) + LineSeries { + id: singleResidualSerie + axisX: axisX + axisY: axisY + useOpenGL: EaGlobals.Vars.useOpenGL + width: 1 + color: { + const models = Globals.BackendWrapper.sampleModels + const idx = Globals.BackendWrapper.sampleCurrentModelIndex + if (models && idx >= 0 && idx < models.length) { + return models[idx].color + } + return EaStyle.Colors.themeForeground + } + visible: !isMultiExperimentMode + onHovered: (point, state) => showMainTooltip(chartView, dataToolTip, point, state) + } + + // Tool buttons + Row { + id: toolButtons + z: 1 + + x: chartView.plotArea.x + chartView.plotArea.width - width + y: chartView.plotArea.y - height - EaStyle.Sizes.fontPixelSize + + spacing: 0.25 * EaStyle.Sizes.fontPixelSize + + EaElements.TabButton { + checked: root.showLegend + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "align-left" + ToolTip.text: root.showLegend ? qsTr("Hide legend") : qsTr("Show legend") + onClicked: root.showLegend = checked + } + + EaElements.TabButton { + checked: chartView.allowHover + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "comment-alt" + ToolTip.text: qsTr("Show coordinates tooltip on hover") + onClicked: chartView.allowHover = checked + } + + Item { height: 1; width: 0.5 * EaStyle.Sizes.fontPixelSize } // spacer + + EaElements.TabButton { + checked: !chartView.allowZoom + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "arrows-alt" + ToolTip.text: qsTr("Enable pan") + onClicked: chartView.allowZoom = !checked + } + + EaElements.TabButton { + checked: chartView.allowZoom + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "expand" + ToolTip.text: qsTr("Enable box zoom") + onClicked: chartView.allowZoom = checked + } + + EaElements.TabButton { + checkable: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "backspace" + ToolTip.text: qsTr("Reset axes") + onClicked: chartView.resetAxes() + } + } + + // Legend + Rectangle { + visible: root.showLegend + + x: chartView.plotArea.x + chartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize + y: chartView.plotArea.y + EaStyle.Sizes.fontPixelSize + width: childrenRect.width + height: childrenRect.height + + color: EaStyle.Colors.mainContentBackgroundHalfTransparent + border.color: EaStyle.Colors.chartGridLine + + Column { + leftPadding: EaStyle.Sizes.fontPixelSize + rightPadding: EaStyle.Sizes.fontPixelSize + topPadding: EaStyle.Sizes.fontPixelSize * 0.5 + bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 + spacing: EaStyle.Sizes.fontPixelSize * 0.25 + + // Single experiment + EaElements.Label { + visible: !isMultiExperimentMode + text: '━ ' + qsTr('Residual') + color: singleResidualSerie.color + } + + // Multi-experiment + Repeater { + model: isMultiExperimentMode ? Globals.BackendWrapper.plottingIndividualExperimentDataList : [] + delegate: Row { + spacing: EaStyle.Sizes.fontPixelSize * 0.3 + + Rectangle { + width: EaStyle.Sizes.fontPixelSize * 0.8 + height: 3 + color: modelData.color || '#1f77b4' + anchors.verticalCenter: parent.verticalCenter + } + + EaElements.Label { + text: modelData.name || `Exp ${index + 1}` + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.8 + color: EaStyle.Colors.themeForeground + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + } + + EaElements.ToolTip { + id: dataToolTip + arrowLength: 0 + textFormat: Text.RichText + } + + // Zoom rectangle + Rectangle { + id: recZoom + + property int xScaleZoom: 0 + property int yScaleZoom: 0 + + visible: false + transform: Scale { + origin.x: 0 + origin.y: 0 + xScale: recZoom.xScaleZoom + yScale: recZoom.yScaleZoom + } + border.color: EaStyle.Colors.appBorder + border.width: 1 + opacity: 0.9 + color: 'transparent' + + Rectangle { + anchors.fill: parent + opacity: 0.5 + color: recZoom.border.color + } + } + + // Zoom with left mouse button + MouseArea { + id: zoomMouseArea + + enabled: chartView.allowZoom + anchors.fill: chartView + acceptedButtons: Qt.LeftButton + onPressed: { + recZoom.x = mouseX + recZoom.y = mouseY + recZoom.visible = true + } + onMouseXChanged: { + if (mouseX > recZoom.x) { + recZoom.xScaleZoom = 1 + recZoom.width = Math.min(mouseX, chartView.width) - recZoom.x + } else { + recZoom.xScaleZoom = -1 + recZoom.width = recZoom.x - Math.max(mouseX, 0) + } + } + onMouseYChanged: { + if (mouseY > recZoom.y) { + recZoom.yScaleZoom = 1 + recZoom.height = Math.min(mouseY, chartView.height) - recZoom.y + } else { + recZoom.yScaleZoom = -1 + recZoom.height = recZoom.y - Math.max(mouseY, 0) + } + } + onReleased: { + const x = Math.min(recZoom.x, mouseX) - chartView.anchors.leftMargin + const y = Math.min(recZoom.y, mouseY) - chartView.anchors.topMargin + const width = recZoom.width + const height = recZoom.height + chartView.zoomIn(Qt.rect(x, y, width, height)) + recZoom.visible = false + } + } + + // Pan with left mouse button + MouseArea { + property real pressedX + property real pressedY + property int threshold: 1 + + enabled: !zoomMouseArea.enabled + anchors.fill: chartView + acceptedButtons: Qt.LeftButton + onPressed: { + pressedX = mouseX + pressedY = mouseY + } + onMouseXChanged: Qt.callLater(update) + onMouseYChanged: Qt.callLater(update) + + function update() { + const dx = mouseX - pressedX + const dy = mouseY - pressedY + pressedX = mouseX + pressedY = mouseY + + if (dx > threshold) chartView.scrollLeft(dx) + else if (dx < -threshold) chartView.scrollRight(-dx) + if (dy > threshold) chartView.scrollUp(dy) + else if (dy < -threshold) chartView.scrollDown(-dy) + } + } + + // Reset axes with right mouse button + MouseArea { + anchors.fill: chartView + acceptedButtons: Qt.RightButton + onClicked: chartView.resetAxes() + } + } + + // Multi-experiment mode flag + property bool isMultiExperimentMode: { + try { return Globals.BackendWrapper.plottingIsMultiExperimentMode || false } + catch (e) { return false } + } + + // Re-populate charts when backend signals a data/range refresh + Connections { + target: Globals.BackendWrapper.activeBackend?.plotting ?? null + enabled: target !== null + function onSampleChartRangesChanged() { + refreshResidualChart() + } + } + + Component.onCompleted: { + Qt.callLater(refreshResidualChart) + } + + function refreshResidualChart() { + // Update zero-line span to match new range + zeroLine.clear() + zeroLine.append(Globals.BackendWrapper.plottingResidualMinX, 0) + zeroLine.append(Globals.BackendWrapper.plottingResidualMaxX, 0) + + if (isMultiExperimentMode) { + _refreshMultiExperiment() + } else { + _refreshSingleExperiment() + } + } + + function _refreshSingleExperiment() { + // Remove any lingering multi-experiment series + _clearMultiExperimentSeries() + + singleResidualSerie.clear() + const expIdx = Globals.BackendWrapper.analysisExperimentsCurrentIndex + const points = Globals.BackendWrapper.plottingGetResidualDataPoints(expIdx) + for (let i = 0; i < points.length; i++) { + singleResidualSerie.append(points[i].x, points[i].y) + } + } + + function _refreshMultiExperiment() { + singleResidualSerie.clear() + _clearMultiExperimentSeries() + + const experimentDataList = Globals.BackendWrapper.plottingIndividualExperimentDataList + for (let i = 0; i < experimentDataList.length; i++) { + const expData = experimentDataList[i] + if (!expData.hasData) continue + + const serie = chartView.createSeries(ChartView.SeriesTypeLine, + expData.name || `Exp ${i + 1}`, + axisX, axisY) + serie.color = expData.color + serie.width = 1 + serie.useOpenGL = EaGlobals.Vars.useOpenGL + serie.hovered.connect((point, state) => showMainTooltip(chartView, dataToolTip, point, state)) + + const points = Globals.BackendWrapper.plottingGetResidualDataPoints(expData.index) + for (let j = 0; j < points.length; j++) { + serie.append(points[j].x, points[j].y) + } + + residualSeries.push(serie) + } + } + + function _clearMultiExperimentSeries() { + for (let i = 0; i < residualSeries.length; i++) { + if (residualSeries[i]) chartView.removeSeries(residualSeries[i]) + } + residualSeries = [] + } + + function showMainTooltip(chart, tooltip, point, state) { + if (!chart.allowHover) return + const pos = chart.mapToPosition(Qt.point(point.x, point.y)) + tooltip.x = pos.x + tooltip.y = pos.y + tooltip.text = `

q: ${point.x.toFixed(4)}
σ: ${point.y.toFixed(3)}

` + tooltip.parent = chart + tooltip.visible = state + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml index 31399d53..7c531479 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml @@ -1,19 +1,76 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import EasyApp.Gui.Style as EaStyle import Gui as Gui import Gui.Globals as Globals -Gui.SldChart { - id: sldChart +Item { + id: root + + // Expose the SLD chartView so existing Globals.References remain valid + readonly property alias sldChartView: sldChart.chartView + + // Called by CombinedView to reset both lower tabs together + function resetAllAxes() { + sldChart.chartView.resetAxes() + residualsView.chartView.resetAxes() + } + + // Called by CombinedView to sync pan/zoom mode from the top toolbar + function setAllowZoom(value) { + sldChart.chartView.allowZoom = value + residualsView.chartView.allowZoom = value + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + TabBar { + id: tabBar + Layout.fillWidth: true + Layout.preferredHeight: EaStyle.Sizes.toolButtonHeight + + background: Rectangle { color: EaStyle.Colors.chartBackground } - showLegend: Globals.Variables.showLegendOnAnalysisPage + TabButton { + text: qsTr("SLD") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + implicitHeight: EaStyle.Sizes.toolButtonHeight + } + TabButton { + text: qsTr("Residuals") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + implicitHeight: EaStyle.Sizes.toolButtonHeight + } + } - onShowLegendChanged: Globals.Variables.showLegendOnAnalysisPage = showLegend + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: tabBar.currentIndex + + Gui.SldChart { + id: sldChart + showLegend: Globals.Variables.showLegendOnAnalysisPage + onShowLegendChanged: Globals.Variables.showLegendOnAnalysisPage = showLegend + } + + ResidualsView { + id: residualsView + showLegend: Globals.Variables.showLegendOnAnalysisResidualsTab + onShowLegendChanged: Globals.Variables.showLegendOnAnalysisResidualsTab = showLegend + } + } + } Component.onCompleted: { Globals.References.pages.analysis.mainContent.sldView = sldChart.chartView diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Calculator.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Calculator.qml index 2b1cb1ad..79488b88 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Calculator.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Calculator.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml index f568d96b..4b41223d 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/ParamNames.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/ParamNames.qml index cca55bf3..731f05ef 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/ParamNames.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/ParamNames.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/PlotControl.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/PlotControl.qml index 34de6956..4715b956 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/PlotControl.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/PlotControl.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Layout.qml index deeee520..dd11888f 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Layout.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml index b66b7a9e..32ec1930 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls @@ -343,7 +343,6 @@ EaElements.GroupBox { EaComponents.TableViewParameter { enabled: Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? Globals.BackendWrapper.analysisFitableParameters[index].independent : true - minored: true text: EaLogic.Utils.toDefaultPrecision(Globals.BackendWrapper.analysisFitableParameters[index].min).replace('Infinity', 'inf') onEditingFinished: { focus = false @@ -356,7 +355,6 @@ EaElements.GroupBox { EaComponents.TableViewParameter { enabled: Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? Globals.BackendWrapper.analysisFitableParameters[index].independent : true - minored: true text: EaLogic.Utils.toDefaultPrecision(Globals.BackendWrapper.analysisFitableParameters[index].max).replace('Infinity', 'inf') onEditingFinished: { focus = false diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml index f4ccbfe7..6bc6f3a8 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Layout.qml index 4b2fb4ad..eaa7a7cd 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Layout.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick @@ -28,23 +28,4 @@ EaComponents.SideBarColumn { // } Groups.Fittables{} - -/* EaElements.GroupBox { - //title: qsTr("Parameters") - collapsible: false - last: true - - Loader { source: 'Fittables.qml' } - } -*/ - - Groups.Fitting{} - -/* EaElements.GroupBox { - //title: qsTr("Fitting") - collapsible: false - - Loader { source: 'Fitting.qml' } - } -*/ } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml index 74984f63..85a5e2b2 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 070e809d..f3202d98 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml index ccf27b4d..eec8e7fc 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Layout.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Layout.qml index 600ca675..57e358f9 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Layout.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick diff --git a/EasyReflectometryApp/Gui/Pages/Project/MainContent/Description.qml b/EasyReflectometryApp/Gui/Pages/Project/MainContent/Description.qml index eab0faa6..99570bef 100644 --- a/EasyReflectometryApp/Gui/Pages/Project/MainContent/Description.qml +++ b/EasyReflectometryApp/Gui/Pages/Project/MainContent/Description.qml @@ -1,6 +1,6 @@ -// 5SPDX-FileCopyrightText: 2025 EasyApp contributors +// 5SPDX-FileCopyrightText: 2026 EasyApp contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyApp project +// © 2026 Contributors to the EasyApp project import QtQuick diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml index 3801d2e6..08cb4711 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml index 38c821cd..214e453b 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml index d8e0fe08..c70ef9c2 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml index b448193c..abfc9278 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Summary/Layout.qml b/EasyReflectometryApp/Gui/Pages/Summary/Layout.qml index cafa75e3..a95b03aa 100644 --- a/EasyReflectometryApp/Gui/Pages/Summary/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Summary/Layout.qml @@ -1,6 +1,6 @@ -// 5SPDX-FileCopyrightText: 2025 EasyApp contributors +// 5SPDX-FileCopyrightText: 2026 EasyApp contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyApp project +// © 2026 Contributors to the EasyApp project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Summary/MainContent/Summary.qml b/EasyReflectometryApp/Gui/Pages/Summary/MainContent/Summary.qml index 929e4b64..2007fdbe 100644 --- a/EasyReflectometryApp/Gui/Pages/Summary/MainContent/Summary.qml +++ b/EasyReflectometryApp/Gui/Pages/Summary/MainContent/Summary.qml @@ -1,6 +1,6 @@ -// 5SPDX-FileCopyrightText: 2025 EasyApp contributors +// 5SPDX-FileCopyrightText: 2026 EasyApp contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyApp project +// © 2026 Contributors to the EasyApp project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/Export.qml b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/Export.qml index 5fc4398d..824a4714 100644 --- a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/Export.qml +++ b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/Export.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyApp contributors +// SPDX-FileCopyrightText: 2026 EasyApp contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyApp project +// © 2026 Contributors to the EasyApp project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/ExportPlots.qml b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/ExportPlots.qml index 1ca5353a..4dff9dff 100644 --- a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/ExportPlots.qml +++ b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/ExportPlots.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyApp contributors +// SPDX-FileCopyrightText: 2026 EasyApp contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyApp project +// © 2026 Contributors to the EasyApp project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/SaveConfirmationDialog.qml b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/SaveConfirmationDialog.qml index 60821c00..52d381c3 100644 --- a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/SaveConfirmationDialog.qml +++ b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/SaveConfirmationDialog.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyApp contributors +// SPDX-FileCopyrightText: 2026 EasyApp contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyApp project +// © 2026 Contributors to the EasyApp project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Layout.qml b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Layout.qml index 785186ca..eff2e592 100644 --- a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Layout.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyApp contributors +// SPDX-FileCopyrightText: 2026 EasyApp contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyApp project +// © 2026 Contributors to the EasyApp project import QtQuick diff --git a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Extra/Groups/Empty.qml b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Extra/Groups/Empty.qml index e4a92b86..a91951f0 100644 --- a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Extra/Groups/Empty.qml +++ b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Extra/Groups/Empty.qml @@ -1,6 +1,6 @@ -// 5SPDX-FileCopyrightText: 2025 EasyApp contributors +// 5SPDX-FileCopyrightText: 2026 EasyApp contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyApp project +// © 2026 Contributors to the EasyApp project import QtQuick diff --git a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Extra/Layout.qml b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Extra/Layout.qml index b5e18516..7a78b596 100644 --- a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Extra/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Extra/Layout.qml @@ -1,6 +1,6 @@ -// 5SPDX-FileCopyrightText: 2025 EasyApp contributors +// 5SPDX-FileCopyrightText: 2026 EasyApp contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyApp project +// © 2026 Contributors to the EasyApp project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/PlotControlRefLines.qml b/EasyReflectometryApp/Gui/PlotControlRefLines.qml index e1ee3eeb..6024ddf9 100644 --- a/EasyReflectometryApp/Gui/PlotControlRefLines.qml +++ b/EasyReflectometryApp/Gui/PlotControlRefLines.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/Gui/SldChart.qml b/EasyReflectometryApp/Gui/SldChart.qml index 908b28b3..b560b610 100644 --- a/EasyReflectometryApp/Gui/SldChart.qml +++ b/EasyReflectometryApp/Gui/SldChart.qml @@ -1,6 +1,6 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors // SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project +// © 2026 Contributors to the EasyReflectometry project import QtQuick import QtQuick.Controls diff --git a/EasyReflectometryApp/main.py b/EasyReflectometryApp/main.py index e89a6b59..e7dc8875 100644 --- a/EasyReflectometryApp/main.py +++ b/EasyReflectometryApp/main.py @@ -1,6 +1,6 @@ -# 5SPDX-FileCopyrightText: 2025 EasyReflectometryApp contributors +# 5SPDX-FileCopyrightText: 2026 EasyReflectometryApp contributors # SPDX-License-Identifier: BSD-3-Clause -# © 2025 Contributors to the EasyReflectometryApp project +# © 2026 Contributors to the EasyReflectometryApp project import argparse import sys from pathlib import Path diff --git a/LICENSE.md b/LICENSE.md index d1859c1a..d29371d4 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2021-2025, European Spallation Source +Copyright (c) 2021-2026, European Spallation Source All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/pyproject.toml b/pyproject.toml index e016f5b8..43813895 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ requires-python = '>=3.11' dependencies = [ - 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@develop', + 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@footer_component', 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@develop', 'asteval', 'PySide6', diff --git a/tests/factories.py b/tests/factories.py index 031a80aa..147f33bf 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -302,6 +302,15 @@ def __init__(self, success=True, chi2=1.0, n_pars=1, x=None, reduced_chi=0.5, mi class FakeProject: + @property + def calculator(self): + return self._calculator_name + + @calculator.setter + def calculator(self, value): + self._calculator_name = value + self._calculator.switched_to = value + @property def _current_model_index(self): return self.current_model_index @@ -330,7 +339,7 @@ def __init__( self.current_assembly_index = 0 self.current_layer_index = 0 self._calculator = FakeCalculatorController(calculator_interfaces or ['refnx', 'refl1d']) - self.calculator = calculator_name + self._calculator_name = calculator_name self.minimizer = FakeMinimizerValue(minimizer_name) self._fitter = None self.fitter = None diff --git a/tests/test_logic_fitting.py b/tests/test_logic_fitting.py index 8dc844d2..5cbf671e 100644 --- a/tests/test_logic_fitting.py +++ b/tests/test_logic_fitting.py @@ -95,7 +95,7 @@ def test_on_fit_finished_and_fit_properties_cover_multi_and_single_results(): assert logic.fit_finished is True assert logic.fit_success is True assert logic.fit_n_pars == 4 - assert logic.fit_chi2 == 10.0 + assert logic.fit_chi2 == 2.0 logic.on_fit_finished(make_fit_result(success=False, chi2=9.0, n_pars=1, x=[1, 2], reduced_chi=4.5)) assert logic.fit_success is False diff --git a/tests/test_logic_project.py b/tests/test_logic_project.py index 699fa613..971e01e0 100644 --- a/tests/test_logic_project.py +++ b/tests/test_logic_project.py @@ -72,7 +72,7 @@ def test_project_info_and_delegated_file_operations(): logic.load_experiment('exp.ort') logic.load_new_experiment('new.ort') assert logic.count_datasets_in_file('file.ort') == 3 - assert logic.load_all_experiments_from_file('file.ort') == 2 + assert logic.load_all_experiments_from_file('file.ort') == (2, False) assert ('create',) in project_lib.calls assert ('save_as_json', False) in project_lib.calls diff --git a/tests/test_py_backend.py b/tests/test_py_backend.py index 044b68b6..1ef7ed87 100644 --- a/tests/test_py_backend.py +++ b/tests/test_py_backend.py @@ -1,4 +1,5 @@ from types import SimpleNamespace +from unittest.mock import MagicMock from PySide6.QtCore import QObject from PySide6.QtCore import Signal @@ -34,6 +35,7 @@ class StubSample(QObject): modelsIndexChanged = Signal() assembliesTableChanged = Signal() assembliesIndexChanged = Signal() + qRangeChanged = Signal() def __init__(self, _project_lib): super().__init__() @@ -46,6 +48,7 @@ def _clearCacheAndEmitLayersChanged(self): class StubExperiment(QObject): externalExperimentChanged = Signal() experimentChanged = Signal() + qRangeUpdated = Signal() def __init__(self, _project_lib): super().__init__() @@ -211,3 +214,90 @@ def test_backend_refresh_plots_emits_ranges_and_multi_signal(monkeypatch, qcore_ assert backend.plottingIndividualExperimentDataList == [{'name': 'E0', 'index': 0, 'color': '#111111', 'hasData': True}] assert backend.plottingGetExperimentDataPoints(3) == [{'x': 3.0, 'y': 0.0}] assert backend.plottingGetAnalysisDataPoints(5) == [{'x': 5.0, 'measured': 0.0, 'calculated': 0.0}] + + +# =========================================================================== +# Delegation-contract tests. +# These verify that PyBackend correctly forwards calls to Plotting1d without +# requiring the full Qt infrastructure — Plotting1d is replaced by MagicMock. +# =========================================================================== + +class _DelegationStub: + """Minimal replica of the PyBackend delegation methods under test.""" + + def __init__(self, plotting_1d): + self._plotting_1d = plotting_1d + + def plottingGetAnalysisDataPoints(self, experiment_index: int) -> list: + return self._plotting_1d.getAnalysisDataPoints(experiment_index) + + def plottingGetResidualDataPoints(self, experiment_index: int) -> list: + return self._plotting_1d.getResidualDataPoints(experiment_index) + + +class TestPlottingGetResidualDataPointsDelegation: + def _backend(self): + mock_plotting = MagicMock() + return _DelegationStub(mock_plotting), mock_plotting + + def test_delegates_to_plotting_1d(self): + backend, plotting = self._backend() + expected = [{'x': 0.1, 'y': 0.002}, {'x': 0.2, 'y': -0.001}] + plotting.getResidualDataPoints.return_value = expected + + result = backend.plottingGetResidualDataPoints(0) + + plotting.getResidualDataPoints.assert_called_once_with(0) + assert result == expected + + def test_passes_experiment_index(self): + backend, plotting = self._backend() + plotting.getResidualDataPoints.return_value = [] + + backend.plottingGetResidualDataPoints(3) + + plotting.getResidualDataPoints.assert_called_once_with(3) + + def test_returns_empty_list_when_plotting_returns_empty(self): + backend, plotting = self._backend() + plotting.getResidualDataPoints.return_value = [] + + result = backend.plottingGetResidualDataPoints(0) + + assert result == [] + + def test_returns_result_unchanged(self): + backend, plotting = self._backend() + payload = [{'x': i * 0.1, 'y': i * 0.001} for i in range(10)] + plotting.getResidualDataPoints.return_value = payload + + result = backend.plottingGetResidualDataPoints(0) + + assert result is payload + + +class TestPlottingGetAnalysisDataPointsDelegation: + """Regression: existing analysis bridging is unaffected by residual additions.""" + + def _backend(self): + mock_plotting = MagicMock() + return _DelegationStub(mock_plotting), mock_plotting + + def test_delegates_to_plotting_1d(self): + backend, plotting = self._backend() + expected = [{'x': 0.1, 'measured': -2.0, 'calculated': -1.9}] + plotting.getAnalysisDataPoints.return_value = expected + + result = backend.plottingGetAnalysisDataPoints(0) + + plotting.getAnalysisDataPoints.assert_called_once_with(0) + assert result == expected + + def test_passes_experiment_index(self): + backend, plotting = self._backend() + plotting.getAnalysisDataPoints.return_value = [] + + backend.plottingGetAnalysisDataPoints(5) + + plotting.getAnalysisDataPoints.assert_called_once_with(5) + diff --git a/tests/test_py_experiment.py b/tests/test_py_experiment.py index 9f3277f4..b1483b4b 100644 --- a/tests/test_py_experiment.py +++ b/tests/test_py_experiment.py @@ -33,6 +33,7 @@ def count_datasets_in_file(self, path): def load_all_experiments_from_file(self, path): self.loaded_all.append(path) + return (2, False) def load_new_experiment(self, path): self.loaded_new.append(path) diff --git a/tests/test_py_plotting_1d.py b/tests/test_py_plotting_1d.py index 4731c002..6d5bb1dc 100644 --- a/tests/test_py_plotting_1d.py +++ b/tests/test_py_plotting_1d.py @@ -1,6 +1,8 @@ from types import SimpleNamespace +from unittest.mock import MagicMock import numpy as np +import pytest from PySide6.QtCore import QObject from EasyReflectometryApp.Backends.Py.plotting_1d import Plotting1d @@ -189,8 +191,291 @@ def test_plotting_multi_experiment_mode_refreshes_via_signal(qcore_application): assert count['changed'] == 2 +def test_plotting_analysis_refresh_skips_when_series_not_registered(qcore_application): + plotting, _project = _make_plotting() + plotting._chartRefs['QtCharts']['analysisPage']['measuredSerie'] = None + plotting._chartRefs['QtCharts']['analysisPage']['calculatedSerie'] = None + + plotting.drawCalculatedAndMeasuredOnAnalysisChart() + + def test_plotting_get_model_color_fallback(qcore_application): plotting, _project = _make_plotting() assert plotting.getModelColor(0) == '#111111' assert plotting.getModelColor(100) == '#000000' + + +# =========================================================================== +# Stub-based unit tests for residual / analysis helper methods. +# These run without a live QCoreApplication because Plotting1d is instantiated +# via __new__ with all internals set directly (no Qt signal machinery needed +# for the pure-Python helper methods under test). +# =========================================================================== + +class _DataSet1DStub: + """Minimal DataSet1D for stub-based tests (no PySide6 required).""" + def __init__(self, name='', x=None, y=None, ye=None, xe=None): + self.x = x if x is not None else np.empty(0) + self.y = y if y is not None else np.empty(0) + self.ye = ye if ye is not None else np.empty(0) + self.xe = xe if xe is not None else np.empty(0) + + def data_points(self): + for i in range(len(self.x)): + yield (self.x[i], self.y[i], + self.ye[i] ** 2 if len(self.ye) > i else 0.0) + + +def _make_exp_data_stub(q, r, ye=None): + if ye is None: + ye = np.zeros_like(r) + return _DataSet1DStub(name='test', x=q, y=r, ye=ye) + + +def _make_project_stub(q, r_exp, r_calc, q_min=0.0, q_max=1.0, ye=None): + proj = MagicMock() + proj.q_min = q_min + proj.q_max = q_max + proj.current_experiment_index = 0 + proj.current_model_index = 0 + proj.experimental_data_for_model_at_index.return_value = _make_exp_data_stub(q, r_exp, ye) + proj.model_data_for_model_at_index.return_value = _DataSet1DStub(name='calc', x=q, y=r_calc) + proj.models = [MagicMock()] + return proj + + +def _make_plotting_stub(project, rq4=False): + proxy = MagicMock() + proxy._analysis._selected_experiment_indices = [0] + p = Plotting1d.__new__(Plotting1d) + p._project_lib = project + p._proxy = proxy + p._plot_rq4 = rq4 + p._x_axis_log = False + p._sld_x_reversed = False + p._scale_shown = False + p._bkg_shown = False + p._sample_data = {} + p._model_data = {} + p._sld_data = {} + p._residual_range_cache = None + p._chartRefs = {'QtCharts': {'samplePage': {}, 'experimentPage': {}, 'analysisPage': {}}} + return p + + +# --------------------------------------------------------------------------- +# _get_aligned_analysis_values +# --------------------------------------------------------------------------- + +class TestGetAlignedAnalysisValues: + def test_returns_correct_q_count(self): + q = np.array([0.05, 0.10, 0.15, 0.20]) + r_exp = np.array([1e-1, 1e-2, 1e-3, 1e-4]) + r_calc = np.array([1.1e-1, 1.1e-2, 1.1e-3, 1.1e-4]) + proj = _make_project_stub(q, r_exp, r_calc, q_min=0.0, q_max=0.25) + p = _make_plotting_stub(proj) + + result = p._get_aligned_analysis_values(0) + assert len(result) == len(q) + + def test_filters_outside_q_range(self): + q = np.array([0.01, 0.05, 0.10, 0.50]) + r_exp = np.array([1e-1, 1e-2, 1e-3, 1e-4]) + r_calc = np.array([1.1e-1, 1.1e-2, 1.1e-3, 1.1e-4]) + proj = _make_project_stub(q, r_exp, r_calc, q_min=0.02, q_max=0.40) + p = _make_plotting_stub(proj) + + result = p._get_aligned_analysis_values(0) + returned_q = [pt['q'] for pt in result] + assert 0.01 not in returned_q + assert 0.50 not in returned_q + assert 0.05 in returned_q + assert 0.10 in returned_q + + def test_keys_present(self): + q = np.array([0.05, 0.10]) + r_exp = np.array([1e-2, 1e-3]) + r_calc = np.array([1.1e-2, 1.1e-3]) + proj = _make_project_stub(q, r_exp, r_calc) + p = _make_plotting_stub(proj) + + result = p._get_aligned_analysis_values(0) + for pt in result: + assert 'q' in pt + assert 'measured' in pt + assert 'calculated' in pt + assert 'sigma' in pt + + def test_linear_space_no_log(self): + q = np.array([0.10]) + r_exp = np.array([1e-2]) + r_calc = np.array([2e-2]) + proj = _make_project_stub(q, r_exp, r_calc) + p = _make_plotting_stub(proj) + + result = p._get_aligned_analysis_values(0) + assert len(result) == 1 + assert pytest.approx(result[0]['measured'], rel=1e-6) == 1e-2 + assert pytest.approx(result[0]['calculated'], rel=1e-6) == 2e-2 + + def test_rq4_applied_in_linear_space(self): + q = np.array([0.10]) + r_exp = np.array([1e-2]) + r_calc = np.array([2e-2]) + proj = _make_project_stub(q, r_exp, r_calc) + p = _make_plotting_stub(proj, rq4=True) + + result = p._get_aligned_analysis_values(0) + expected_meas = 1e-2 * (0.10 ** 4) + expected_calc = 2e-2 * (0.10 ** 4) + assert pytest.approx(result[0]['measured'], rel=1e-6) == expected_meas + assert pytest.approx(result[0]['calculated'], rel=1e-6) == expected_calc + + +# --------------------------------------------------------------------------- +# getAnalysisDataPoints +# --------------------------------------------------------------------------- + +class TestGetAnalysisDataPoints: + def test_applies_log10(self): + q = np.array([0.10]) + r_exp = np.array([1e-2]) + r_calc = np.array([2e-2]) + proj = _make_project_stub(q, r_exp, r_calc) + p = _make_plotting_stub(proj) + + result = p.getAnalysisDataPoints(0) + assert len(result) == 1 + assert pytest.approx(result[0]['measured'], rel=1e-6) == np.log10(1e-2) + assert pytest.approx(result[0]['calculated'], rel=1e-6) == np.log10(2e-2) + + def test_returns_empty_on_error(self): + proj = MagicMock() + proj.experimental_data_for_model_at_index.side_effect = RuntimeError('boom') + proj.q_min = 0.0 + proj.q_max = 1.0 + p = _make_plotting_stub(proj) + assert p.getAnalysisDataPoints(0) == [] + + +# --------------------------------------------------------------------------- +# getResidualDataPoints +# --------------------------------------------------------------------------- + +class TestGetResidualDataPoints: + def test_residual_is_calc_minus_meas(self): + """Normalized residual = (calc − meas) / sigma when sigma is given.""" + q = np.array([0.10]) + r_exp = np.array([1e-2]) + r_calc = np.array([3e-2]) + ye = np.array([1e-3]) # sigma = 1e-3 + proj = _make_project_stub(q, r_exp, r_calc, ye=ye) + p = _make_plotting_stub(proj) + + result = p.getResidualDataPoints(0) + assert len(result) == 1 + expected = (3e-2 - 1e-2) / 1e-3 # 20.0 + assert pytest.approx(result[0]['y'], rel=1e-6) == expected + + def test_residual_rq4_mode(self): + """Normalized residual is invariant under the rq4 transform because + sigma scales the same way as (calc - meas), so the q^4 factor cancels. + """ + q = np.array([0.10]) + r_exp = np.array([1e-2]) + r_calc = np.array([3e-2]) + ye = np.array([1e-3]) + proj = _make_project_stub(q, r_exp, r_calc, ye=ye) + p_linear = _make_plotting_stub(proj, rq4=False) + p_rq4 = _make_plotting_stub(proj, rq4=True) + + res_linear = p_linear.getResidualDataPoints(0) + res_rq4 = p_rq4.getResidualDataPoints(0) + assert pytest.approx(res_rq4[0]['y'], rel=1e-6) == res_linear[0]['y'] + + def test_residual_zero_when_identical(self): + q = np.array([0.05, 0.10, 0.15]) + r = np.array([1e-1, 1e-2, 1e-3]) + ye = np.array([0.01, 0.001, 0.0001]) + proj = _make_project_stub(q, r, r.copy(), ye=ye) + p = _make_plotting_stub(proj) + + result = p.getResidualDataPoints(0) + for pt in result: + assert pytest.approx(pt['y'], abs=1e-12) == 0.0 + + def test_residual_x_matches_q(self): + q = np.array([0.05, 0.10, 0.15]) + r_exp = np.array([1e-1, 1e-2, 1e-3]) + r_calc = np.array([1.1e-1, 1.1e-2, 1.1e-3]) + proj = _make_project_stub(q, r_exp, r_calc) + p = _make_plotting_stub(proj) + + result = p.getResidualDataPoints(0) + returned_x = [pt['x'] for pt in result] + assert returned_x == pytest.approx(list(q), rel=1e-6) + + def test_returns_empty_on_error(self): + proj = MagicMock() + proj.experimental_data_for_model_at_index.side_effect = RuntimeError('boom') + proj.q_min = 0.0 + proj.q_max = 1.0 + p = _make_plotting_stub(proj) + assert p.getResidualDataPoints(0) == [] + + +# --------------------------------------------------------------------------- +# _get_residual_range +# --------------------------------------------------------------------------- + +class TestGetResidualRange: + def test_fallback_when_no_data(self): + proj = MagicMock() + proj.experimental_data_for_model_at_index.side_effect = RuntimeError('no data') + proj.q_min = 0.0 + proj.q_max = 1.0 + proj.models = [] + p = _make_plotting_stub(proj) + rng = p._get_residual_range() + assert rng == (0.0, 1.0, -1.0, 1.0) + + def test_x_range_matches_q_domain(self): + q = np.array([0.05, 0.10, 0.20]) + r_exp = np.array([1e-1, 1e-2, 1e-3]) + r_calc = np.array([1.1e-1, 1.1e-2, 1.1e-3]) + proj = _make_project_stub(q, r_exp, r_calc) + p = _make_plotting_stub(proj) + + rng = p._get_residual_range() + assert pytest.approx(rng[0], rel=1e-6) == q.min() + assert pytest.approx(rng[1], rel=1e-6) == q.max() + + def test_y_range_has_margin(self): + q = np.array([0.10]) + r_exp = np.array([1e-2]) + r_calc = np.array([3e-2]) + ye = np.array([1e-3]) + proj = _make_project_stub(q, r_exp, r_calc, ye=ye) + p = _make_plotting_stub(proj) + + rng = p._get_residual_range() + normalized_residual = (3e-2 - 1e-2) / 1e-3 # 20.0 + assert rng[2] < normalized_residual + assert rng[3] > normalized_residual + + def test_rq4_affects_range(self): + """Normalized residual range is identical in linear and rq4 modes.""" + q = np.array([0.10]) + r_exp = np.array([1e-2]) + r_calc = np.array([3e-2]) + ye = np.array([1e-3]) + proj = _make_project_stub(q, r_exp, r_calc, ye=ye) + + p_linear = _make_plotting_stub(proj, rq4=False) + p_rq4 = _make_plotting_stub(proj, rq4=True) + + rng_linear = p_linear._get_residual_range() + rng_rq4 = p_rq4._get_residual_range() + assert pytest.approx(rng_linear, rel=1e-6) == rng_rq4 + diff --git a/tools/Scripts/InstallerInstallScript.js b/tools/Scripts/InstallerInstallScript.js index 10f1ce63..2e8ef68d 100644 --- a/tools/Scripts/InstallerInstallScript.js +++ b/tools/Scripts/InstallerInstallScript.js @@ -102,7 +102,7 @@ Component.prototype.createOperations = function () { if (installer.value("os") === "x11") { component.addOperation( "CreateDesktopEntry", - "@TargetDir@/@ProductName@.desktop", + "@ProductName@.desktop", "Comment=A scientific software for modelling and analysis of the neutron re data.\n" + "Type=Application\n" + "Exec=@TargetDir@/@ProductName@/@ProductName@\n" + @@ -123,12 +123,6 @@ Component.prototype.createOperations = function () { ) */ - component.addOperation( - "Copy", - "@TargetDir@/@ProductName@.desktop", - "@HomeDir@/.local/share/applications/@ProductName@.desktop" - ) - /* component.addOperation( "Copy", diff --git a/utils.py b/utils.py index e3cc2a05..e194e5f8 100755 --- a/utils.py +++ b/utils.py @@ -1,6 +1,6 @@ -# SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +# SPDX-FileCopyrightText: 2026 EasyReflectometry contributors # SPDX-License-Identifier: BSD-3-Clause -# © 2021-2025 Contributors to the EasyReflectometry project +# © 2021-2026 Contributors to the EasyReflectometry project import argparse import datetime