Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions EasyReflectometryApp/Backends/Mock/Analysis.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 40 additions & 3 deletions EasyReflectometryApp/Backends/Py/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand All @@ -187,21 +222,23 @@ 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:
"""Stop fitting and clean up."""
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()

Expand Down
72 changes: 70 additions & 2 deletions EasyReflectometryApp/Backends/Py/logic/fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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.

Expand All @@ -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."""
Expand All @@ -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."""
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
18 changes: 12 additions & 6 deletions EasyReflectometryApp/Backends/Py/workers/fitter_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions EasyReflectometryApp/Gui/Globals/BackendWrapper.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
5 changes: 4 additions & 1 deletion EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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') }
Expand Down Expand Up @@ -50,7 +54,6 @@ EaComponents.ContentPage {
}

Component.onCompleted: Globals.References.pages.analysis.sidebar.basic.popups.startFittingButton = this
Loader { source: 'Sidebar/Basic/Popups/FitStatusDialog.qml' }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ EaElements.GroupBox {
}

Component.onCompleted: Globals.References.pages.analysis.sidebar.basic.popups.startFittingButton = this
Loader { source: "../Popups/FitStatusDialog.qml" }
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ EaElements.Dialog {
Globals.BackendWrapper.analysisSetShowFitResultsDialog(false)
}

onRejected: {
Globals.BackendWrapper.analysisSetShowFitResultsDialog(false)
}

onClosed: {
Globals.BackendWrapper.analysisSetShowFitResultsDialog(false)
}
Expand Down
28 changes: 27 additions & 1 deletion EasyReflectometryApp/Gui/StatusBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,34 @@ 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: {
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 {
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)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading