diff --git a/EasyReflectometryApp/Backends/Py/logic/calculators.py b/EasyReflectometryApp/Backends/Py/logic/calculators.py index c8ed4002..fd734133 100644 --- a/EasyReflectometryApp/Backends/Py/logic/calculators.py +++ b/EasyReflectometryApp/Backends/Py/logic/calculators.py @@ -13,7 +13,7 @@ def available(self) -> list[str]: def current_index(self) -> int: return self._current_index - def set_current_index(self, new_value: int) -> None: + def set_current_index(self, new_value: int) -> bool: if new_value != self._current_index: self._current_index = new_value new_calculator = self._list_available_calculators[new_value] diff --git a/EasyReflectometryApp/Backends/Py/logic/fitting.py b/EasyReflectometryApp/Backends/Py/logic/fitting.py index 6170d2cd..263da157 100644 --- a/EasyReflectometryApp/Backends/Py/logic/fitting.py +++ b/EasyReflectometryApp/Backends/Py/logic/fitting.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING from typing import List from typing import Optional +from typing import cast from easyreflectometry import Project as ProjectLib from easyscience.fitting import FitResults @@ -31,7 +32,7 @@ def status(self) -> str: if self._result is None: return '' else: - return self._result.success + return str(self._result.success) @property def running(self) -> bool: @@ -105,6 +106,8 @@ def prepare_for_threaded_fit(self) -> None: self._finished = False self._show_results_dialog = False self._fit_error_message = None + self._result = None + self._results = [] def _ordered_experiments(self) -> list: """Return experiments as an ordered list of experiment objects. @@ -118,7 +121,7 @@ def _ordered_experiments(self) -> list: if hasattr(experiments, 'items'): items = list(experiments.items()) try: - items.sort(key=lambda item: item[0]) + items = sorted(items) except TypeError: pass return [experiment for _, experiment in items] @@ -201,7 +204,7 @@ def prepare_threaded_fit(self, minimizers_logic: 'Minimizers') -> tuple: logger.exception('Error preparing threaded fit') return None, None, None, None, None - def on_fit_finished(self, results: List[FitResults]) -> None: + def on_fit_finished(self, results: FitResults | List[FitResults]) -> None: """Handle successful completion of fitting. :param results: List of FitResults from the multi-fitter. @@ -219,25 +222,28 @@ def on_fit_finished(self, results: List[FitResults]) -> None: engine_name = getattr(results[0], 'minimizer_engine', 'unknown') logger.info('Fit finished: engine=%s, chi2=%s, success=%s', engine_name, self.fit_chi2, results[0].success) else: - self._result = results - self._results = [results] if results else [] + single_result = cast(Optional[FitResults], results) + self._result = single_result + self._results = [single_result] if single_result is not None else [] @property def fit_n_pars(self) -> int: """Return the global number of refined parameters for the fit.""" if self._results: - return sum(r.n_pars for r in self._results) + return sum(result.n_pars for result in self._results) if self._result is None: return 0 return self._result.n_pars @property def fit_chi2(self) -> float: - """Return reduced chi-squared across all fits (chi2 / degrees of freedom)""" + """Return reduced chi-squared across all fits.""" if self._results: try: - total_chi2 = float(sum(r.chi2 for r in self._results)) - total_points = sum(len(r.x) for r in self._results) + if len(self._results) == 1: + return float(self._results[0].reduced_chi) + total_chi2 = float(sum(result.chi2 for result in self._results)) + total_points = sum(len(result.x) for result in self._results) n_params = self._results[0].n_pars total_dof = total_points - n_params if total_dof <= 0: diff --git a/EasyReflectometryApp/Backends/Py/logic/project.py b/EasyReflectometryApp/Backends/Py/logic/project.py index c5b8b92c..05b71d4b 100644 --- a/EasyReflectometryApp/Backends/Py/logic/project.py +++ b/EasyReflectometryApp/Backends/Py/logic/project.py @@ -8,6 +8,7 @@ class Project: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib + self._last_q_range_changed = False self._project_lib.default_model() self._update_enablement_of_fixed_layers_for_model(0) diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index 81730e1b..b3f5fddd 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -562,6 +562,22 @@ def individualExperimentDataList(self) -> list: ) return qml_data_list + @Property(float, notify=sampleChartRangesChanged) + def residualMinX(self): + return self._get_residual_range()[0] + + @Property(float, notify=sampleChartRangesChanged) + def residualMaxX(self): + return self._get_residual_range()[1] + + @Property(float, notify=sampleChartRangesChanged) + def residualMinY(self): + return self._get_residual_range()[2] + + @Property(float, notify=sampleChartRangesChanged) + def residualMaxY(self): + return self._get_residual_range()[3] + @Slot(str, str, 'QVariant') def setQtChartsSerieRef(self, page: str, serie: str, ref: QObject): self._chartRefs['QtCharts'][page][serie] = ref @@ -640,66 +656,70 @@ 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:: + def _get_experiment_model_index(self, experiment_index: int, exp_data=None) -> int: + """Resolve the model index used by a given experiment.""" + if exp_data is not None and 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: + return idx + if experiment_index < len(self._project_lib.models): + return experiment_index + return 0 - {'q': float, 'measured': float, 'calculated': float} - """ - # Get measured experimental data + def _get_aligned_analysis_values(self, experiment_index: int) -> list[dict]: + """Return measured, calculated and sigma values aligned on experiment q points.""" exp_data = self._project_lib.experimental_data_for_model_at_index(experiment_index) + q_values = np.asarray(getattr(exp_data, 'x', np.empty(0)), dtype=float) + measured_values = np.asarray(getattr(exp_data, 'y', np.empty(0)), dtype=float) + sigma_values = np.asarray(getattr(exp_data, 'ye', np.zeros_like(measured_values)), dtype=float) - # 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 + if q_values.size == 0 or measured_values.size == 0: + return [] - # 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] + q_mask = (q_values >= self._project_lib.q_min) & (q_values <= self._project_lib.q_max) + q_filtered = q_values[q_mask] + measured_filtered = measured_values[q_mask] + sigma_filtered = sigma_values[q_mask] if sigma_values.size else np.zeros_like(measured_filtered) - # 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 + model_index = self._get_experiment_model_index(experiment_index, exp_data) + try: + calc_data = self._project_lib.model_data_for_model_at_index(model_index, q_filtered) + except TypeError: + calc_data = self._project_lib.model_data_for_model_at_index(model_index) + + calc_values = np.asarray(getattr(calc_data, 'y', np.empty(0)), dtype=float) + calc_q_values = np.asarray(getattr(calc_data, 'x', np.empty(0)), dtype=float) + + if calc_values.size == q_filtered.size: + calculated_filtered = calc_values + elif calc_values.size == 0: + calculated_filtered = measured_filtered.copy() + elif calc_q_values.size == calc_values.size and calc_values.size > 1: + calculated_filtered = np.interp(q_filtered, calc_q_values, calc_values) + elif calc_values.size == 1: + calculated_filtered = np.full_like(measured_filtered, calc_values[0], dtype=float) + else: + calculated_filtered = np.resize(calc_values, q_filtered.size) - 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}') + measured_filtered = self._apply_rq4(q_filtered, measured_filtered) + calculated_filtered = self._apply_rq4(q_filtered, calculated_filtered) + sigma_filtered = self._apply_rq4(q_filtered, sigma_filtered) 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 + for q_value, measured_value, calculated_value, sigma_value in zip( + q_filtered, + measured_filtered, + calculated_filtered, + sigma_filtered, + ): + points.append( + { + 'q': float(q_value), + 'measured': float(measured_value), + 'calculated': float(calculated_value), + 'sigma': float(sigma_value), + } + ) return points @Slot(int, result='QVariantList') @@ -707,15 +727,16 @@ def getAnalysisDataPoints(self, experiment_index: int) -> list: """Get measured and calculated data points for a specific experiment for analysis plotting.""" try: 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, - }) + for point in self._get_aligned_analysis_values(experiment_index): + measured = point['measured'] + calculated = point['calculated'] + points.append( + { + 'x': point['q'], + 'measured': float(np.log10(measured)) if measured > 0 else -10.0, + 'calculated': float(np.log10(calculated)) if calculated > 0 else -10.0, + } + ) return points except Exception as e: console.debug(f'Error getting analysis data points for index {experiment_index}: {e}') @@ -723,29 +744,55 @@ def getAnalysisDataPoints(self, experiment_index: int) -> list: @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). - """ + """Get residual data points for a specific experiment.""" try: points = [] - 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)}) + for point in self._get_aligned_analysis_values(experiment_index): + sigma = point['sigma'] + residual = point['calculated'] - point['measured'] + if sigma > 0: + residual = residual / sigma + points.append({'x': point['q'], 'y': float(residual)}) return points except Exception as e: console.debug(f'Error getting residual data points for index {experiment_index}: {e}') return [] + def _get_residual_range(self) -> tuple[float, float, float, float]: + """Return residual plot ranges for the current selection.""" + try: + if self.is_multi_experiment_mode: + selected_indices = getattr(self._proxy._analysis, '_selected_experiment_indices', []) + else: + selected_indices = [self._project_lib.current_experiment_index] + + all_points = [] + for experiment_index in selected_indices: + all_points.extend(self.getResidualDataPoints(experiment_index)) + + if not all_points: + return 0.0, 1.0, -1.0, 1.0 + + x_values = np.asarray([point['x'] for point in all_points], dtype=float) + y_values = np.asarray([point['y'] for point in all_points], dtype=float) + if x_values.size == 0 or y_values.size == 0: + return 0.0, 1.0, -1.0, 1.0 + + min_x = float(np.min(x_values)) + max_x = float(np.max(x_values)) + min_y = float(np.min(y_values)) + max_y = float(np.max(y_values)) + + if min_y == max_y: + margin = max(abs(min_y) * 0.05, 1.0) + else: + margin = (max_y - min_y) * 0.05 + + return min_x, max_x, min_y - margin, max_y + margin + except Exception as e: + console.debug(f'Error getting residual range: {e}') + return 0.0, 1.0, -1.0, 1.0 + def refreshSamplePage(self): # Clear cached data so it gets recalculated self._sample_data = {} @@ -763,7 +810,6 @@ 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): diff --git a/EasyReflectometryApp/Backends/Py/py_backend.py b/EasyReflectometryApp/Backends/Py/py_backend.py index 2712eadd..bafa69ad 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -137,7 +137,7 @@ def plottingGetAnalysisDataPoints(self, experiment_index: int) -> list: @Slot(int, result='QVariantList') def plottingGetResidualDataPoints(self, experiment_index: int) -> list: - """Get residual data points (model - experiment) for a specific experiment.""" + """Get residual data points for a specific experiment for residual plotting.""" return self._plotting_1d.getResidualDataPoints(experiment_index) ######### Connections to relay info between the backend parts @@ -165,7 +165,8 @@ def _connect_sample_page(self) -> None: 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) + if hasattr(self._experiment, 'qRangeUpdated') and hasattr(self._sample, 'qRangeChanged'): + self._experiment.qRangeUpdated.connect(self._sample.qRangeChanged) def _connect_analysis_page(self) -> None: self._analysis.externalMinimizerChanged.connect(self._relay_analysis_page) diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml index 85a5e2b2..45826681 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml @@ -45,7 +45,7 @@ EaElements.Dialog { EaElements.Label { visible: Globals.BackendWrapper.analysisFitSuccess - text: "Chi2: " + Globals.BackendWrapper.analysisFitChi2.toFixed(4) + text: "Reduced Chi2: " + Globals.BackendWrapper.analysisFitChi2.toFixed(4) } EaElements.Label { diff --git a/EasyReflectometryApp/Gui/StatusBar.qml b/EasyReflectometryApp/Gui/StatusBar.qml index 9afb2c96..ff0c676e 100644 --- a/EasyReflectometryApp/Gui/StatusBar.qml +++ b/EasyReflectometryApp/Gui/StatusBar.qml @@ -56,8 +56,8 @@ EaElements.StatusBar { EaElements.StatusBarItem { visible: Globals.BackendWrapper.analysisFitChi2 > 0 keyIcon: 'chart-line' - keyText: qsTr('Chi²') + keyText: qsTr('Reduced Chi²') valueText: Globals.BackendWrapper.analysisFitChi2.toFixed(2) - ToolTip.text: qsTr('Goodness of fit (chi-squared)') + ToolTip.text: qsTr('Goodness of fit (reduced chi-squared)') } } diff --git a/tests/test_logic_fitting.py b/tests/test_logic_fitting.py index 5cbf671e..fcdc3de0 100644 --- a/tests/test_logic_fitting.py +++ b/tests/test_logic_fitting.py @@ -100,7 +100,7 @@ def test_on_fit_finished_and_fit_properties_cover_multi_and_single_results(): 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 assert logic.fit_n_pars == 1 - assert logic.fit_chi2 == 9.0 + assert logic.fit_chi2 == 4.5 def test_fit_failure_and_cancellation_state_transitions():