Skip to content
Merged
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
2 changes: 1 addition & 1 deletion EasyReflectometryApp/Backends/Py/logic/calculators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
24 changes: 15 additions & 9 deletions EasyReflectometryApp/Backends/Py/logic/fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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]
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions EasyReflectometryApp/Backends/Py/logic/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
202 changes: 124 additions & 78 deletions EasyReflectometryApp/Backends/Py/plotting_1d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -640,112 +656,143 @@ 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')
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}')
return []

@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 = {}
Expand All @@ -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):
Expand Down
5 changes: 3 additions & 2 deletions EasyReflectometryApp/Backends/Py/py_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions EasyReflectometryApp/Gui/StatusBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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)')
}
}
2 changes: 1 addition & 1 deletion tests/test_logic_fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading