From 10a026ba475da1dce16a23607f285d3309e6fbea Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 14 Apr 2026 10:59:49 +0200 Subject: [PATCH 1/3] initial implementation for lmfit --- .../Backends/Mock/Analysis.qml | 7 + EasyReflectometryApp/Backends/Py/analysis.py | 43 ++++- .../Backends/Py/logic/fitting.py | 72 +++++++- .../Backends/Py/workers/fitter_worker.py | 18 +- .../Gui/Globals/BackendWrapper.qml | 7 + .../Gui/Pages/Analysis/Layout.qml | 5 +- .../Analysis/Sidebar/Basic/Groups/Fitting.qml | 1 - .../Sidebar/Basic/Popups/FitStatusDialog.qml | 4 + EasyReflectometryApp/Gui/StatusBar.qml | 13 +- tests/test_analysis.py | 170 ++++++++++++++++++ tests/test_logic_fitting.py | 73 +++++++- tests/test_workers_fitter_worker.py | 37 +++- 12 files changed, 433 insertions(+), 17 deletions(-) create mode 100644 tests/test_analysis.py diff --git a/EasyReflectometryApp/Backends/Mock/Analysis.qml b/EasyReflectometryApp/Backends/Mock/Analysis.qml index 8865debb..00687c00 100644 --- a/EasyReflectometryApp/Backends/Mock/Analysis.qml +++ b/EasyReflectometryApp/Backends/Mock/Analysis.qml @@ -25,6 +25,13 @@ QtObject { readonly property string fitErrorMessage: '' readonly property int fitNumRefinedParams: 3 readonly property real fitChi2: 1.2345 + readonly property int fitIteration: 0 + readonly property real fitInterimChi2: 0.0 + readonly property real fitInterimReducedChi2: 0.0 + readonly property string fitProgressMessage: '' + readonly property bool fitHasInterimUpdate: false + readonly property bool fitHasPreviewUpdate: false + readonly property var fitPreviewParameterValues: ({}) readonly property var fitResults: ({ success: true, nvarys: 3, chi2: 1.2345 }) // Fit failure signal (mirrors Python backend) diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index 8a5b6335..14217d4d 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -118,6 +118,34 @@ def fitNumRefinedParams(self) -> int: def fitChi2(self) -> float: return self._fitting_logic.fit_chi2 + @Property(int, notify=fittingChanged) + def fitIteration(self) -> int: + return self._fitting_logic.fit_iteration + + @Property(float, notify=fittingChanged) + def fitInterimChi2(self) -> float: + return self._fitting_logic.fit_interim_chi2 + + @Property(float, notify=fittingChanged) + def fitInterimReducedChi2(self) -> float: + return self._fitting_logic.fit_interim_reduced_chi2 + + @Property(str, notify=fittingChanged) + def fitProgressMessage(self) -> str: + return self._fitting_logic.fit_progress_message + + @Property(bool, notify=fittingChanged) + def fitHasInterimUpdate(self) -> bool: + return self._fitting_logic.fit_has_interim_update + + @Property(bool, notify=fittingChanged) + def fitHasPreviewUpdate(self) -> bool: + return self._fitting_logic.fit_has_preview_update + + @Property('QVariant', notify=fittingChanged) + def fitPreviewParameterValues(self) -> dict: + return self._fitting_logic.fit_preview_parameter_values + @Property('QVariant', notify=fittingChanged) def fitResults(self) -> dict: """Return fit results as a dict for QML consumption.""" @@ -171,10 +199,17 @@ def _start_threaded_fit(self) -> None: self._fitter_thread.setTerminationEnabled(True) self._fitter_thread.finished.connect(self._on_fit_finished) self._fitter_thread.failed.connect(self._on_fit_failed) + self._fitter_thread.progressDetail.connect(self._on_fit_progress) self._fitter_thread.finished.connect(self._fitter_thread.deleteLater) self._fitter_thread.failed.connect(self._fitter_thread.deleteLater) self._fitter_thread.start() + @Slot(dict) + def _on_fit_progress(self, payload: dict) -> None: + """Handle in-flight progress payloads emitted from the worker thread.""" + self._fitting_logic.on_fit_progress(payload) + self.fittingChanged.emit() + @Slot(list) def _on_fit_finished(self, results: list) -> None: """Handle successful completion of threaded fit.""" @@ -187,12 +222,16 @@ def _on_fit_finished(self, results: list) -> None: @Slot(str) def _on_fit_failed(self, error_message: str) -> None: """Handle failed threaded fit.""" + is_user_cancel = self._fitting_logic.fit_cancelled and 'cancel' in error_message.lower() + if is_user_cancel: + error_message = 'Fitting cancelled by user' self._fitting_logic.on_fit_failed(error_message) self._fitter_thread = None self.fittingChanged.emit() self._clearCacheAndEmitParametersChanged() self.externalFittingChanged.emit() - self.fitFailed.emit(error_message) + if not is_user_cancel: + self.fitFailed.emit(error_message) @Slot() def _onStopFit(self) -> None: @@ -200,8 +239,6 @@ def _onStopFit(self) -> None: self._fitting_logic.stop_fit() if self._fitter_thread is not None: self._fitter_thread.stop() - self._fitter_thread.deleteLater() - self._fitter_thread = None self.fittingChanged.emit() self.externalFittingChanged.emit() diff --git a/EasyReflectometryApp/Backends/Py/logic/fitting.py b/EasyReflectometryApp/Backends/Py/logic/fitting.py index 263da157..919e92ff 100644 --- a/EasyReflectometryApp/Backends/Py/logic/fitting.py +++ b/EasyReflectometryApp/Backends/Py/logic/fitting.py @@ -5,6 +5,7 @@ from typing import cast from easyreflectometry import Project as ProjectLib +from easyreflectometry.utils import count_free_parameters from easyscience.fitting import FitResults from easyscience.fitting.minimizers.utils import FitError @@ -26,6 +27,13 @@ def __init__(self, project_lib: ProjectLib): self._fit_error_message: Optional[str] = None self._fit_cancelled = False self._stop_requested = False + self._fit_iteration = 0 + self._fit_interim_chi2 = 0.0 + self._fit_interim_reduced_chi2 = 0.0 + self._fit_running_message = '' + self._fit_preview_parameter_values: dict = {} + self._fit_has_preview_update = False + self._fit_has_interim_update = False @property def status(self) -> str: @@ -68,6 +76,61 @@ def fit_cancelled(self) -> bool: """Return True if fit was cancelled by user.""" return self._fit_cancelled + @property + def fit_iteration(self) -> int: + return self._fit_iteration + + @property + def fit_interim_chi2(self) -> float: + return self._fit_interim_chi2 + + @property + def fit_interim_reduced_chi2(self) -> float: + return self._fit_interim_reduced_chi2 + + @property + def fit_progress_message(self) -> str: + return self._fit_running_message + + @property + def fit_preview_parameter_values(self) -> dict: + return dict(self._fit_preview_parameter_values) + + @property + def fit_has_preview_update(self) -> bool: + return self._fit_has_preview_update + + @property + def fit_has_interim_update(self) -> bool: + return self._fit_has_interim_update + + def on_fit_progress(self, payload: dict) -> None: + """Update transient state from an in-flight fit progress payload.""" + self._fit_iteration = int(payload.get('iteration', 0) or 0) + self._fit_interim_chi2 = float(payload.get('chi2', 0.0) or 0.0) + self._fit_interim_reduced_chi2 = float( + payload.get('reduced_chi2', self._fit_interim_chi2) or self._fit_interim_chi2 + ) + self._fit_preview_parameter_values = dict(payload.get('parameter_values', {}) or {}) + self._fit_has_preview_update = bool(payload.get('refresh_plots', False)) + self._fit_has_interim_update = True + + if self._fit_iteration > 0: + self._fit_running_message = ( + f'Fitting... iter {self._fit_iteration}, Chi2 = {self._fit_interim_chi2:.6g}' + ) + else: + self._fit_running_message = 'Fitting...' + + def clear_fit_progress(self) -> None: + self._fit_iteration = 0 + self._fit_interim_chi2 = 0.0 + self._fit_interim_reduced_chi2 = 0.0 + self._fit_running_message = '' + self._fit_preview_parameter_values = {} + self._fit_has_preview_update = False + self._fit_has_interim_update = False + def on_fit_failed(self, error_message: str) -> None: """Handle fitting failure callback. @@ -79,6 +142,7 @@ def on_fit_failed(self, error_message: str) -> None: self._running = False self._finished = True self._show_results_dialog = True + self.clear_fit_progress() def stop_fit(self) -> None: """Request fitting to stop and clean up state.""" @@ -90,6 +154,7 @@ def stop_fit(self) -> None: self._fit_cancelled = True self._fit_error_message = 'Fitting cancelled by user' self._show_results_dialog = True + self.clear_fit_progress() def reset_stop_flag(self) -> None: """Reset the stop request flag before starting a new fit.""" @@ -108,6 +173,8 @@ def prepare_for_threaded_fit(self) -> None: self._fit_error_message = None self._result = None self._results = [] + self.clear_fit_progress() + self._fit_running_message = 'Fitting...' def _ordered_experiments(self) -> list: """Return experiments as an ordered list of experiment objects. @@ -213,6 +280,7 @@ def on_fit_finished(self, results: FitResults | List[FitResults]) -> None: self._finished = True self._show_results_dialog = True self._fit_error_message = None + self.clear_fit_progress() # Store result(s) - handle both single and multiple results if isinstance(results, list) and len(results) > 0: @@ -229,8 +297,8 @@ def on_fit_finished(self, results: FitResults | List[FitResults]) -> None: @property def fit_n_pars(self) -> int: """Return the global number of refined parameters for the fit.""" - if self._results: - return sum(result.n_pars for result in self._results) + if len(self._results) > 1: + return count_free_parameters(self._project_lib) if self._result is None: return 0 return self._result.n_pars diff --git a/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py b/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py index cb36c5f6..306ab5ed 100644 --- a/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py +++ b/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py @@ -48,6 +48,9 @@ class FitterWorker(QThread): # Signal emitted to report fitting progress (0-100) progress = Signal(int) + # Detailed fitting progress payload emitted from minimizer callbacks + progressDetail = Signal(dict) + def __init__( self, fitter: Any, @@ -93,7 +96,10 @@ def run(self) -> None: try: # Get the method and call it method = getattr(self._fitter, self._method_name) - result = method(*self._args, **self._kwargs) + kwargs = dict(self._kwargs) + if self._method_name == 'fit' and 'progress_callback' not in kwargs: + kwargs['progress_callback'] = self._progress_callback + result = method(*self._args, **kwargs) # NOTE: This check only catches stop requests that occurred AFTER the fit # completed but before we emit the result. It does NOT interrupt the fitting @@ -116,6 +122,11 @@ def run(self) -> None: error_message = f'{type(ex).__name__}: Unknown error during fitting' self.failed.emit(error_message) + def _progress_callback(self, payload: dict) -> bool: + """Relay plain progress payloads from the minimizer to Qt signals.""" + self.progressDetail.emit(dict(payload)) + return not self._stop_requested + def stop(self) -> None: """ Request the fitting operation to stop. @@ -140,11 +151,6 @@ def stop(self) -> None: potential future improvements (e.g., using subprocess instead of QThread). """ self._stop_requested = True - if self.isRunning(): - # WARNING: terminate() is dangerous but necessary since fitting - # libraries don't support graceful cancellation. See docstring above. - self.terminate() - self.wait() @property def stop_requested(self) -> bool: diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 6c08039e..2a29b05e 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -274,6 +274,13 @@ QtObject { readonly property string analysisFitErrorMessage: activeBackend.analysis.fitErrorMessage readonly property int analysisFitNumRefinedParams: activeBackend.analysis.fitNumRefinedParams readonly property real analysisFitChi2: activeBackend.analysis.fitChi2 + readonly property int analysisFitIteration: activeBackend.analysis.fitIteration + readonly property real analysisFitInterimChi2: activeBackend.analysis.fitInterimChi2 + readonly property real analysisFitInterimReducedChi2: activeBackend.analysis.fitInterimReducedChi2 + readonly property string analysisFitProgressMessage: activeBackend.analysis.fitProgressMessage + readonly property bool analysisFitHasInterimUpdate: activeBackend.analysis.fitHasInterimUpdate + readonly property bool analysisFitHasPreviewUpdate: activeBackend.analysis.fitHasPreviewUpdate + readonly property var analysisFitPreviewParameterValues: activeBackend.analysis.fitPreviewParameterValues readonly property var analysisFitResults: activeBackend.analysis.fitResults function analysisFittingStartStop() { activeBackend.analysis.fittingStartStop() } function analysisSetShowFitResultsDialog(value) { activeBackend.analysis.setShowFitResultsDialog(value) } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml index 0cc4951e..53e62979 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml @@ -13,6 +13,10 @@ import Gui.Globals as Globals EaComponents.ContentPage { + Loader { + source: 'Sidebar/Basic/Popups/FitStatusDialog.qml' + } + mainView: EaComponents.MainContent { tabs: [ EaElements.TabButton { text: qsTr('Reflectivity') } @@ -50,7 +54,6 @@ EaComponents.ContentPage { } Component.onCompleted: Globals.References.pages.analysis.sidebar.basic.popups.startFittingButton = this - Loader { source: 'Sidebar/Basic/Popups/FitStatusDialog.qml' } } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml index 6bc6f3a8..fcac53ef 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml @@ -28,7 +28,6 @@ EaElements.GroupBox { } Component.onCompleted: Globals.References.pages.analysis.sidebar.basic.popups.startFittingButton = this - Loader { source: "../Popups/FitStatusDialog.qml" } } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml index 45826681..59306346 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml @@ -25,6 +25,10 @@ EaElements.Dialog { Globals.BackendWrapper.analysisSetShowFitResultsDialog(false) } + onRejected: { + Globals.BackendWrapper.analysisSetShowFitResultsDialog(false) + } + onClosed: { Globals.BackendWrapper.analysisSetShowFitResultsDialog(false) } diff --git a/EasyReflectometryApp/Gui/StatusBar.qml b/EasyReflectometryApp/Gui/StatusBar.qml index ff0c676e..249596fa 100644 --- a/EasyReflectometryApp/Gui/StatusBar.qml +++ b/EasyReflectometryApp/Gui/StatusBar.qml @@ -53,8 +53,19 @@ EaElements.StatusBar { valueText: Globals.BackendWrapper.statusVariables ?? '' ToolTip.text: qsTr('Number of parameters: total, free and fixed') } + + EaElements.StatusBarItem { + visible: Globals.BackendWrapper.analysisFittingRunning + keyIcon: 'play-circle' + keyText: qsTr('Fit') + valueText: Globals.BackendWrapper.analysisFitHasInterimUpdate + ? Globals.BackendWrapper.analysisFitInterimReducedChi2.toFixed(4) + : Globals.BackendWrapper.analysisFitProgressMessage + ToolTip.text: qsTr('Current fitting progress') + } + EaElements.StatusBarItem { - visible: Globals.BackendWrapper.analysisFitChi2 > 0 + visible: !Globals.BackendWrapper.analysisFittingRunning && Globals.BackendWrapper.analysisFitChi2 > 0 keyIcon: 'chart-line' keyText: qsTr('Reduced Chi²') valueText: Globals.BackendWrapper.analysisFitChi2.toFixed(2) diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 00000000..e42e5332 --- /dev/null +++ b/tests/test_analysis.py @@ -0,0 +1,170 @@ +from unittest.mock import MagicMock + +from PySide6.QtCore import QObject +from PySide6.QtCore import Signal + +from EasyReflectometryApp.Backends.Py import analysis as analysis_module +from EasyReflectometryApp.Backends.Py.logic.fitting import Fitting +from tests.factories import make_project + + +class StubParametersLogic: + def __init__(self, _project_lib): + pass + + +class StubCalculatorsLogic: + def __init__(self, _project_lib): + pass + + +class StubExperimentLogic: + def __init__(self, project_lib): + self._project_lib = project_lib + + def available(self): + return ['Exp 1'] + + def current_index(self): + return 0 + + +class StubMinimizersLogic: + def __init__(self, _project_lib): + self.tolerance = None + self.max_iterations = None + + def selected_minimizer_enum(self): + return None + + +class StubWorker(QObject): + finished = Signal(list) + failed = Signal(str) + progressDetail = Signal(dict) + + instances = [] + + def __init__(self, fitter, method_name, args=(), kwargs=None, parent=None): + super().__init__(parent) + self.fitter = fitter + self.method_name = method_name + self.args = args + self.kwargs = kwargs or {} + self.parent = parent + self.stop_calls = 0 + self.start_calls = 0 + self.delete_calls = 0 + self.termination_enabled = None + StubWorker.instances.append(self) + + def setTerminationEnabled(self, value): + self.termination_enabled = value + + def start(self): + self.start_calls += 1 + + def stop(self): + self.stop_calls += 1 + + def deleteLater(self): + self.delete_calls += 1 + + +def _make_analysis(monkeypatch): + project = make_project() + monkeypatch.setattr(analysis_module, 'ParametersLogic', StubParametersLogic) + monkeypatch.setattr(analysis_module, 'CalculatorsLogic', StubCalculatorsLogic) + monkeypatch.setattr(analysis_module, 'ExperimentLogic', StubExperimentLogic) + monkeypatch.setattr(analysis_module, 'MinimizersLogic', StubMinimizersLogic) + monkeypatch.setattr(analysis_module, 'FitterWorker', StubWorker) + analysis = analysis_module.Analysis(project) + analysis._clearCacheAndEmitParametersChanged = MagicMock() + return analysis + + +def test_start_threaded_fit_propagates_progress_to_properties(monkeypatch, qcore_application): + StubWorker.instances = [] + analysis = _make_analysis(monkeypatch) + analysis._fitting_logic.prepare_threaded_fit = MagicMock( + return_value=('fake-fitter', ['x'], ['y'], ['w'], None) + ) + fitting_changed = {'count': 0} + analysis.fittingChanged.connect( + lambda: fitting_changed.__setitem__('count', fitting_changed['count'] + 1) + ) + + analysis._start_threaded_fit() + + worker = StubWorker.instances[-1] + worker.progressDetail.emit( + { + 'iteration': 9, + 'chi2': 3.5, + 'reduced_chi2': 1.4, + 'parameter_values': {'thickness': 12.0}, + 'refresh_plots': False, + 'finished': False, + } + ) + + assert worker.method_name == 'fit' + assert worker.kwargs == {'weights': ['w'], 'method': None} + assert worker.start_calls == 1 + assert analysis.fittingRunning is True + assert analysis.fitIteration == 9 + assert analysis.fitInterimChi2 == 3.5 + assert analysis.fitInterimReducedChi2 == 1.4 + assert analysis.fitProgressMessage == 'Fitting... iter 9, Chi2 = 3.5' + assert analysis.fitHasInterimUpdate is True + assert analysis.fitHasPreviewUpdate is False + assert analysis.fitPreviewParameterValues == {'thickness': 12.0} + assert fitting_changed['count'] >= 2 + + +def test_on_stop_fit_requests_worker_stop_without_immediate_cleanup(monkeypatch, qcore_application): + StubWorker.instances = [] + analysis = _make_analysis(monkeypatch) + analysis._fitting_logic.prepare_threaded_fit = MagicMock( + return_value=('fake-fitter', ['x'], ['y'], ['w'], None) + ) + + analysis._start_threaded_fit() + worker = StubWorker.instances[-1] + + analysis._onStopFit() + + assert worker.stop_calls == 1 + assert analysis._fitter_thread is worker + assert analysis.fittingRunning is False + assert analysis.fitErrorMessage == 'Fitting cancelled by user' + + +def test_fitting_start_stop_emits_stop_signal_when_fit_is_running(monkeypatch, qcore_application): + analysis = _make_analysis(monkeypatch) + analysis._fitting_logic.prepare_for_threaded_fit() + received = {'count': 0} + analysis.stopFit.connect(lambda: received.__setitem__('count', received['count'] + 1)) + + analysis.fittingStartStop() + + assert received['count'] == 1 + + +def test_cancelled_worker_failure_does_not_emit_fit_failed(monkeypatch, qcore_application): + StubWorker.instances = [] + analysis = _make_analysis(monkeypatch) + analysis._fitting_logic = Fitting(make_project()) + analysis._clearCacheAndEmitParametersChanged = MagicMock() + analysis._fitting_logic.prepare_for_threaded_fit() + analysis._fitting_logic.stop_fit() + analysis._fitter_thread = StubWorker('fake-fitter', 'fit') + received = [] + analysis.fitFailed.connect(received.append) + + analysis._on_fit_failed('Fit cancelled by progress callback') + + assert analysis._fitter_thread is None + assert analysis.fitErrorMessage == 'Fitting cancelled by user' + assert received == [] + analysis._clearCacheAndEmitParametersChanged.assert_called_once_with() \ No newline at end of file diff --git a/tests/test_logic_fitting.py b/tests/test_logic_fitting.py index fcdc3de0..ec1a137f 100644 --- a/tests/test_logic_fitting.py +++ b/tests/test_logic_fitting.py @@ -82,9 +82,10 @@ def test_prepare_threaded_fit_builds_masked_arrays_and_configures_minimizer(monk assert method is None -def test_on_fit_finished_and_fit_properties_cover_multi_and_single_results(): +def test_on_fit_finished_and_fit_properties_cover_multi_and_single_results(monkeypatch): project = make_project() logic = fitting_module.Fitting(project) + monkeypatch.setattr(fitting_module, 'count_free_parameters', lambda current_project: 2) logic.prepare_for_threaded_fit() logic.on_fit_finished([ @@ -94,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_n_pars == 2 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)) @@ -103,6 +104,74 @@ def test_on_fit_finished_and_fit_properties_cover_multi_and_single_results(): assert logic.fit_chi2 == 4.5 +def test_fit_n_pars_uses_global_free_parameter_count_for_multi_experiment_results(monkeypatch): + project = make_project() + logic = fitting_module.Fitting(project) + monkeypatch.setattr(fitting_module, 'count_free_parameters', lambda current_project: 3) + + logic.prepare_for_threaded_fit() + logic.on_fit_finished([ + make_fit_result(success=True, chi2=4.0, n_pars=3, x=[1, 2, 3], reduced_chi=1.1), + make_fit_result(success=True, chi2=6.0, n_pars=3, x=[1, 2, 3, 4], reduced_chi=1.2), + ]) + + assert logic.fit_n_pars == 3 + + +def test_fit_progress_updates_transient_state_and_message(): + project = make_project() + logic = fitting_module.Fitting(project) + + logic.prepare_for_threaded_fit() + logic.on_fit_progress( + { + 'iteration': 12, + 'chi2': 4.25, + 'reduced_chi2': 1.75, + 'parameter_values': {'alpha': 2.0}, + 'refresh_plots': True, + 'finished': False, + } + ) + + assert logic.fit_iteration == 12 + assert logic.fit_interim_chi2 == 4.25 + assert logic.fit_interim_reduced_chi2 == 1.75 + assert logic.fit_preview_parameter_values == {'alpha': 2.0} + assert logic.fit_has_preview_update is True + assert logic.fit_has_interim_update is True + assert logic.fit_progress_message == 'Fitting... iter 12, Chi2 = 4.25' + + +def test_fit_progress_state_resets_on_finish_failure_and_stop(): + project = make_project() + logic = fitting_module.Fitting(project) + + logic.prepare_for_threaded_fit() + logic.on_fit_progress({'iteration': 3, 'chi2': 8.0, 'parameter_values': {'beta': 1.0}}) + logic.on_fit_finished(make_fit_result(success=True, chi2=8.0, n_pars=1, x=[1, 2], reduced_chi=4.0)) + + assert logic.fit_iteration == 0 + assert logic.fit_progress_message == '' + assert logic.fit_has_interim_update is False + + logic.prepare_for_threaded_fit() + logic.on_fit_progress({'iteration': 4, 'chi2': 7.0, 'refresh_plots': True}) + logic.on_fit_failed('boom') + + assert logic.fit_iteration == 0 + assert logic.fit_preview_parameter_values == {} + assert logic.fit_has_preview_update is False + + logic.prepare_for_threaded_fit() + logic.on_fit_progress({'iteration': 5, 'chi2': 6.0}) + logic.stop_fit() + + assert logic.fit_iteration == 0 + assert logic.fit_progress_message == '' + assert logic.fit_has_interim_update is False + + def test_fit_failure_and_cancellation_state_transitions(): project = make_project() logic = fitting_module.Fitting(project) diff --git a/tests/test_workers_fitter_worker.py b/tests/test_workers_fitter_worker.py index e2db5fcc..372d7807 100644 --- a/tests/test_workers_fitter_worker.py +++ b/tests/test_workers_fitter_worker.py @@ -71,6 +71,40 @@ def test_run_uses_fallback_message_for_empty_exception_string(qcore_application) assert received == ['SilentError: Unknown error during fitting'] +def test_run_injects_progress_callback_for_fit_method(qcore_application): + fitter = make_worker_fitter(method_result='ok') + worker = FitterWorker(fitter, 'fit', kwargs={'weights': [1.0]}) + + worker.run() + + _, kwargs = fitter.calls[0] + assert 'progress_callback' in kwargs + assert callable(kwargs['progress_callback']) + assert kwargs['weights'] == [1.0] + + +def test_progress_callback_emits_detail_payload(qcore_application): + worker = FitterWorker(make_worker_fitter(method_result='ok'), 'fit') + received = [] + worker.progressDetail.connect(received.append) + payload = {'iteration': 5, 'chi2': 12.5, 'finished': False} + + should_continue = worker._progress_callback(payload) + + assert should_continue is True + assert received == [payload] + + +def test_progress_callback_requests_stop_when_flagged(qcore_application): + worker = FitterWorker(make_worker_fitter(method_result='ok'), 'fit') + + worker.stop() + + should_continue = worker._progress_callback({'iteration': 1}) + + assert should_continue is False + + def test_stop_sets_flag_without_terminating_idle_thread(qcore_application, monkeypatch): worker = FitterWorker(make_worker_fitter(method_result='ok'), 'fit') terminated = {'terminate': 0, 'wait': 0} @@ -93,4 +127,5 @@ def test_stop_terminates_running_thread(qcore_application, monkeypatch): worker.stop() - assert terminated == {'terminate': 1, 'wait': 1} + assert worker.stop_requested is True + assert terminated == {'terminate': 0, 'wait': 0} From fe4b8a4f164f46fd488efb397c183ad57ccf99b0 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 14 Apr 2026 15:18:49 +0200 Subject: [PATCH 2/3] added counter display in status bar --- EasyReflectometryApp/Gui/StatusBar.qml | 10 ++-- tests/test_qml_fitting_progress_ui.py | 64 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 tests/test_qml_fitting_progress_ui.py diff --git a/EasyReflectometryApp/Gui/StatusBar.qml b/EasyReflectometryApp/Gui/StatusBar.qml index 249596fa..d951d1d1 100644 --- a/EasyReflectometryApp/Gui/StatusBar.qml +++ b/EasyReflectometryApp/Gui/StatusBar.qml @@ -58,9 +58,13 @@ EaElements.StatusBar { visible: Globals.BackendWrapper.analysisFittingRunning keyIcon: 'play-circle' keyText: qsTr('Fit') - valueText: Globals.BackendWrapper.analysisFitHasInterimUpdate - ? Globals.BackendWrapper.analysisFitInterimReducedChi2.toFixed(4) - : Globals.BackendWrapper.analysisFitProgressMessage + valueText: { + if (!Globals.BackendWrapper.analysisFitHasInterimUpdate) + return Globals.BackendWrapper.analysisFitProgressMessage + const iter = Globals.BackendWrapper.analysisFitIteration + const rchi2 = Globals.BackendWrapper.analysisFitInterimReducedChi2.toFixed(4) + return qsTr('iter %1 · χ² %2').arg(iter).arg(rchi2) + } ToolTip.text: qsTr('Current fitting progress') } diff --git a/tests/test_qml_fitting_progress_ui.py b/tests/test_qml_fitting_progress_ui.py new file mode 100644 index 00000000..8c0c45a9 --- /dev/null +++ b/tests/test_qml_fitting_progress_ui.py @@ -0,0 +1,64 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_status_bar_shows_transient_fit_progress_and_hides_final_chi2_while_running(): + status_bar_qml = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'StatusBar.qml' + ).read_text(encoding='utf-8') + + assert "keyText: qsTr('Fit')" in status_bar_qml + assert 'visible: Globals.BackendWrapper.analysisFittingRunning' in status_bar_qml + assert 'Globals.BackendWrapper.analysisFitHasInterimUpdate' in status_bar_qml + assert 'Globals.BackendWrapper.analysisFitInterimReducedChi2.toFixed(4)' in status_bar_qml + assert '!Globals.BackendWrapper.analysisFittingRunning && Globals.BackendWrapper.analysisFitChi2 > 0' in status_bar_qml + + +def test_fit_status_dialog_stays_results_only(): + dialog_qml = ( + ROOT + / 'EasyReflectometryApp' + / 'Gui' + / 'Pages' + / 'Analysis' + / 'Sidebar' + / 'Basic' + / 'Popups' + / 'FitStatusDialog.qml' + ).read_text(encoding='utf-8') + + assert 'visible: Globals.BackendWrapper.analysisShowFitResultsDialog' in dialog_qml + assert 'standardButtons: Dialog.Ok' in dialog_qml + assert 'Refinement Running' not in dialog_qml + assert 'Globals.BackendWrapper.analysisStopFit()' not in dialog_qml + assert 'Globals.BackendWrapper.analysisFitIteration' not in dialog_qml + assert 'Globals.BackendWrapper.analysisFitInterimChi2.toFixed(4)' not in dialog_qml + assert 'Globals.BackendWrapper.analysisFitInterimReducedChi2.toFixed(4)' not in dialog_qml + + +def test_fit_buttons_toggle_between_start_and_cancel_via_start_stop_action(): + layout_qml = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Analysis' / 'Layout.qml' + ).read_text(encoding='utf-8') + fitting_group_qml = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Analysis' / 'Sidebar' / 'Basic' / 'Groups' / 'Fitting.qml' + ).read_text(encoding='utf-8') + + assert "Globals.BackendWrapper.analysisFittingRunning ? qsTr('Cancel fitting') : qsTr('Start fitting')" in layout_qml + assert 'Globals.BackendWrapper.analysisFittingStartStop()' in layout_qml + assert "Globals.BackendWrapper.analysisFittingRunning ? qsTr('Cancel fitting') : qsTr('Start fitting')" in fitting_group_qml + assert 'Globals.BackendWrapper.analysisFittingStartStop()' in fitting_group_qml + + +def test_fit_status_dialog_is_loaded_once_at_stable_page_scope(): + layout_qml = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Analysis' / 'Layout.qml' + ).read_text(encoding='utf-8') + fitting_group_qml = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Analysis' / 'Sidebar' / 'Basic' / 'Groups' / 'Fitting.qml' + ).read_text(encoding='utf-8') + + assert "source: 'Sidebar/Basic/Popups/FitStatusDialog.qml'" in layout_qml + assert 'FitStatusDialog.qml' not in fitting_group_qml \ No newline at end of file From 413ddd501ebcdefa617d47e799d85b4b7b784bcf Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 15 Apr 2026 11:28:13 +0200 Subject: [PATCH 3/3] added DFO display - "Fitting running ..." with increasing dot number --- EasyReflectometryApp/Gui/StatusBar.qml | 21 ++++++++++++++++----- pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/EasyReflectometryApp/Gui/StatusBar.qml b/EasyReflectometryApp/Gui/StatusBar.qml index d951d1d1..1b9c1ff1 100644 --- a/EasyReflectometryApp/Gui/StatusBar.qml +++ b/EasyReflectometryApp/Gui/StatusBar.qml @@ -59,13 +59,24 @@ EaElements.StatusBar { keyIcon: 'play-circle' keyText: qsTr('Fit') valueText: { - if (!Globals.BackendWrapper.analysisFitHasInterimUpdate) - return Globals.BackendWrapper.analysisFitProgressMessage - const iter = Globals.BackendWrapper.analysisFitIteration - const rchi2 = Globals.BackendWrapper.analysisFitInterimReducedChi2.toFixed(4) - return qsTr('iter %1 · χ² %2').arg(iter).arg(rchi2) + if (Globals.BackendWrapper.analysisFitHasInterimUpdate) { + const iter = Globals.BackendWrapper.analysisFitIteration + const rchi2 = Globals.BackendWrapper.analysisFitInterimReducedChi2.toFixed(4) + return qsTr('iter %1 · χ² %2').arg(iter).arg(rchi2) + } + return qsTr('Fitting running') + '.'.repeat(dotCount % 5) } ToolTip.text: qsTr('Current fitting progress') + + property int dotCount: 0 + Timer { + interval: 600 + repeat: true + running: Globals.BackendWrapper.analysisFittingRunning + && !Globals.BackendWrapper.analysisFitHasInterimUpdate + onTriggered: parent.dotCount++ + } + onVisibleChanged: if (!visible) dotCount = 0 } EaElements.StatusBarItem { diff --git a/pyproject.toml b/pyproject.toml index e016f5b8..f683417a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ requires-python = '>=3.11' dependencies = [ 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@develop', - 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@develop', + 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@interim_updates', 'asteval', 'PySide6', 'toml',