Skip to content

Commit 103283a

Browse files
authored
1.2.0 fixes (#299)
* display corrected chi2 in the GUI * make the Fit button visible all the time * make sure refl1d actually works * typo fixed * attempt at properly adding starting menu item on ubuntu * enforce axis reset on q-range change. Reparent easyApp * fixed number of fitted parameters shown in the widget and in the status bar. Reparented to EDL develop * changed copyright year from 2025 to 2026 * update model q-range with experimental range * do not grey out editable min/max values * propagate model name changes to all tabs * display assembly name prefix for analysis parameters * merged develop + minor fixes
1 parent 90945ca commit 103283a

8 files changed

Lines changed: 148 additions & 94 deletions

File tree

EasyReflectometryApp/Backends/Py/logic/calculators.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def available(self) -> list[str]:
1313
def current_index(self) -> int:
1414
return self._current_index
1515

16-
def set_current_index(self, new_value: int) -> None:
16+
def set_current_index(self, new_value: int) -> bool:
1717
if new_value != self._current_index:
1818
self._current_index = new_value
1919
new_calculator = self._list_available_calculators[new_value]

EasyReflectometryApp/Backends/Py/logic/fitting.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import TYPE_CHECKING
33
from typing import List
44
from typing import Optional
5+
from typing import cast
56

67
from easyreflectometry import Project as ProjectLib
78
from easyscience.fitting import FitResults
@@ -31,7 +32,7 @@ def status(self) -> str:
3132
if self._result is None:
3233
return ''
3334
else:
34-
return self._result.success
35+
return str(self._result.success)
3536

3637
@property
3738
def running(self) -> bool:
@@ -105,6 +106,8 @@ def prepare_for_threaded_fit(self) -> None:
105106
self._finished = False
106107
self._show_results_dialog = False
107108
self._fit_error_message = None
109+
self._result = None
110+
self._results = []
108111

109112
def _ordered_experiments(self) -> list:
110113
"""Return experiments as an ordered list of experiment objects.
@@ -118,7 +121,7 @@ def _ordered_experiments(self) -> list:
118121
if hasattr(experiments, 'items'):
119122
items = list(experiments.items())
120123
try:
121-
items.sort(key=lambda item: item[0])
124+
items = sorted(items)
122125
except TypeError:
123126
pass
124127
return [experiment for _, experiment in items]
@@ -201,7 +204,7 @@ def prepare_threaded_fit(self, minimizers_logic: 'Minimizers') -> tuple:
201204
logger.exception('Error preparing threaded fit')
202205
return None, None, None, None, None
203206

204-
def on_fit_finished(self, results: List[FitResults]) -> None:
207+
def on_fit_finished(self, results: FitResults | List[FitResults]) -> None:
205208
"""Handle successful completion of fitting.
206209
207210
:param results: List of FitResults from the multi-fitter.
@@ -219,25 +222,28 @@ def on_fit_finished(self, results: List[FitResults]) -> None:
219222
engine_name = getattr(results[0], 'minimizer_engine', 'unknown')
220223
logger.info('Fit finished: engine=%s, chi2=%s, success=%s', engine_name, self.fit_chi2, results[0].success)
221224
else:
222-
self._result = results
223-
self._results = [results] if results else []
225+
single_result = cast(Optional[FitResults], results)
226+
self._result = single_result
227+
self._results = [single_result] if single_result is not None else []
224228

225229
@property
226230
def fit_n_pars(self) -> int:
227231
"""Return the global number of refined parameters for the fit."""
228232
if self._results:
229-
return sum(r.n_pars for r in self._results)
233+
return sum(result.n_pars for result in self._results)
230234
if self._result is None:
231235
return 0
232236
return self._result.n_pars
233237

234238
@property
235239
def fit_chi2(self) -> float:
236-
"""Return reduced chi-squared across all fits (chi2 / degrees of freedom)"""
240+
"""Return reduced chi-squared across all fits."""
237241
if self._results:
238242
try:
239-
total_chi2 = float(sum(r.chi2 for r in self._results))
240-
total_points = sum(len(r.x) for r in self._results)
243+
if len(self._results) == 1:
244+
return float(self._results[0].reduced_chi)
245+
total_chi2 = float(sum(result.chi2 for result in self._results))
246+
total_points = sum(len(result.x) for result in self._results)
241247
n_params = self._results[0].n_pars
242248
total_dof = total_points - n_params
243249
if total_dof <= 0:

EasyReflectometryApp/Backends/Py/logic/project.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
class Project:
99
def __init__(self, project_lib: ProjectLib):
1010
self._project_lib = project_lib
11+
self._last_q_range_changed = False
1112
self._project_lib.default_model()
1213
self._update_enablement_of_fixed_layers_for_model(0)
1314

EasyReflectometryApp/Backends/Py/plotting_1d.py

Lines changed: 124 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,22 @@ def individualExperimentDataList(self) -> list:
562562
)
563563
return qml_data_list
564564

565+
@Property(float, notify=sampleChartRangesChanged)
566+
def residualMinX(self):
567+
return self._get_residual_range()[0]
568+
569+
@Property(float, notify=sampleChartRangesChanged)
570+
def residualMaxX(self):
571+
return self._get_residual_range()[1]
572+
573+
@Property(float, notify=sampleChartRangesChanged)
574+
def residualMinY(self):
575+
return self._get_residual_range()[2]
576+
577+
@Property(float, notify=sampleChartRangesChanged)
578+
def residualMaxY(self):
579+
return self._get_residual_range()[3]
580+
565581
@Slot(str, str, 'QVariant')
566582
def setQtChartsSerieRef(self, page: str, serie: str, ref: QObject):
567583
self._chartRefs['QtCharts'][page][serie] = ref
@@ -640,112 +656,143 @@ def getExperimentDataPoints(self, experiment_index: int) -> list:
640656
console.debug(f'Error getting experiment data points for index {experiment_index}: {e}')
641657
return []
642658

643-
def _get_aligned_analysis_values(self, experiment_index: int) -> list:
644-
"""Return aligned measured/calculated pairs in linear (rq4-transformed) space.
645-
646-
Both values have ``_apply_rq4`` applied but no log10. Only points within
647-
[q_min, q_max] are included. The caller is responsible for any further
648-
transformation (log10 for display, subtraction for residuals, etc.).
649-
650-
Each element is a dict::
659+
def _get_experiment_model_index(self, experiment_index: int, exp_data=None) -> int:
660+
"""Resolve the model index used by a given experiment."""
661+
if exp_data is not None and hasattr(exp_data, 'model') and exp_data.model is not None:
662+
for idx, model in enumerate(self._project_lib.models):
663+
if model is exp_data.model:
664+
return idx
665+
if experiment_index < len(self._project_lib.models):
666+
return experiment_index
667+
return 0
651668

652-
{'q': float, 'measured': float, 'calculated': float}
653-
"""
654-
# Get measured experimental data
669+
def _get_aligned_analysis_values(self, experiment_index: int) -> list[dict]:
670+
"""Return measured, calculated and sigma values aligned on experiment q points."""
655671
exp_data = self._project_lib.experimental_data_for_model_at_index(experiment_index)
672+
q_values = np.asarray(getattr(exp_data, 'x', np.empty(0)), dtype=float)
673+
measured_values = np.asarray(getattr(exp_data, 'y', np.empty(0)), dtype=float)
674+
sigma_values = np.asarray(getattr(exp_data, 'ye', np.zeros_like(measured_values)), dtype=float)
656675

657-
# Resolve model index, which may differ from experiment_index when multiple
658-
# experiments share the same model.
659-
model_index = 0
660-
model_found = False
661-
if hasattr(exp_data, 'model') and exp_data.model is not None:
662-
for idx, model in enumerate(self._project_lib.models):
663-
if model is exp_data.model:
664-
model_index = idx
665-
model_found = True
666-
break
667-
if not model_found:
668-
console.debug(f'Warning: model for experiment {experiment_index} '
669-
f'not found in models collection, falling back to model 0')
670-
else:
671-
model_index = experiment_index if experiment_index < len(self._project_lib.models) else 0
676+
if q_values.size == 0 or measured_values.size == 0:
677+
return []
672678

673-
# Filter experimental q values to [q_min, q_max]
674-
q_values = exp_data.x
675-
mask = (q_values >= self._project_lib.q_min) & (q_values <= self._project_lib.q_max)
676-
q_filtered = q_values[mask]
679+
q_mask = (q_values >= self._project_lib.q_min) & (q_values <= self._project_lib.q_max)
680+
q_filtered = q_values[q_mask]
681+
measured_filtered = measured_values[q_mask]
682+
sigma_filtered = sigma_values[q_mask] if sigma_values.size else np.zeros_like(measured_filtered)
677683

678-
# Evaluate model at the filtered experimental q points
679-
calc_data = self._project_lib.model_data_for_model_at_index(model_index, q_filtered)
680-
calc_y = calc_data.y
684+
model_index = self._get_experiment_model_index(experiment_index, exp_data)
685+
try:
686+
calc_data = self._project_lib.model_data_for_model_at_index(model_index, q_filtered)
687+
except TypeError:
688+
calc_data = self._project_lib.model_data_for_model_at_index(model_index)
689+
690+
calc_values = np.asarray(getattr(calc_data, 'y', np.empty(0)), dtype=float)
691+
calc_q_values = np.asarray(getattr(calc_data, 'x', np.empty(0)), dtype=float)
692+
693+
if calc_values.size == q_filtered.size:
694+
calculated_filtered = calc_values
695+
elif calc_values.size == 0:
696+
calculated_filtered = measured_filtered.copy()
697+
elif calc_q_values.size == calc_values.size and calc_values.size > 1:
698+
calculated_filtered = np.interp(q_filtered, calc_q_values, calc_values)
699+
elif calc_values.size == 1:
700+
calculated_filtered = np.full_like(measured_filtered, calc_values[0], dtype=float)
701+
else:
702+
calculated_filtered = np.resize(calc_values, q_filtered.size)
681703

682-
if len(calc_y) != len(q_filtered):
683-
console.debug(f'Warning: calculated data length ({len(calc_y)}) '
684-
f'differs from filtered experimental data ({len(q_filtered)}) '
685-
f'for experiment {experiment_index}')
704+
measured_filtered = self._apply_rq4(q_filtered, measured_filtered)
705+
calculated_filtered = self._apply_rq4(q_filtered, calculated_filtered)
706+
sigma_filtered = self._apply_rq4(q_filtered, sigma_filtered)
686707

687708
points = []
688-
calc_idx = 0
689-
for point in exp_data.data_points():
690-
q = point[0]
691-
if self._project_lib.q_min <= q <= self._project_lib.q_max:
692-
r_meas = point[1]
693-
calc_y_val = calc_y[calc_idx] if calc_idx < len(calc_y) else r_meas
694-
sigma_linear = float(np.sqrt(max(point[2], 0.0)))
695-
sigma_transformed = float(self._apply_rq4(q, sigma_linear)) if sigma_linear > 0.0 else 0.0
696-
points.append({
697-
'q': float(q),
698-
'measured': float(self._apply_rq4(q, r_meas)),
699-
'calculated': float(self._apply_rq4(q, calc_y_val)),
700-
'sigma': sigma_transformed,
701-
})
702-
calc_idx += 1
709+
for q_value, measured_value, calculated_value, sigma_value in zip(
710+
q_filtered,
711+
measured_filtered,
712+
calculated_filtered,
713+
sigma_filtered,
714+
):
715+
points.append(
716+
{
717+
'q': float(q_value),
718+
'measured': float(measured_value),
719+
'calculated': float(calculated_value),
720+
'sigma': float(sigma_value),
721+
}
722+
)
703723
return points
704724

705725
@Slot(int, result='QVariantList')
706726
def getAnalysisDataPoints(self, experiment_index: int) -> list:
707727
"""Get measured and calculated data points for a specific experiment for analysis plotting."""
708728
try:
709729
points = []
710-
for item in self._get_aligned_analysis_values(experiment_index):
711-
q = item['q']
712-
r_meas = item['measured']
713-
r_calc = item['calculated']
714-
points.append({
715-
'x': q,
716-
'measured': float(np.log10(r_meas)) if r_meas > 0 else -10.0,
717-
'calculated': float(np.log10(r_calc)) if r_calc > 0 else -10.0,
718-
})
730+
for point in self._get_aligned_analysis_values(experiment_index):
731+
measured = point['measured']
732+
calculated = point['calculated']
733+
points.append(
734+
{
735+
'x': point['q'],
736+
'measured': float(np.log10(measured)) if measured > 0 else -10.0,
737+
'calculated': float(np.log10(calculated)) if calculated > 0 else -10.0,
738+
}
739+
)
719740
return points
720741
except Exception as e:
721742
console.debug(f'Error getting analysis data points for index {experiment_index}: {e}')
722743
return []
723744

724745
@Slot(int, result='QVariantList')
725746
def getResidualDataPoints(self, experiment_index: int) -> list:
726-
"""Get normalized residual data points (model − experiment) / sigma.
727-
728-
Falls back to (model − experiment) / experiment when sigma is zero
729-
(i.e. measurement uncertainty not provided).
730-
"""
747+
"""Get residual data points for a specific experiment."""
731748
try:
732749
points = []
733-
for item in self._get_aligned_analysis_values(experiment_index):
734-
calc = item['calculated']
735-
meas = item['measured']
736-
sigma = item['sigma']
737-
if sigma > 0.0:
738-
residual = (calc - meas) / sigma
739-
elif meas > 0.0:
740-
residual = (calc - meas) / meas
741-
else:
742-
residual = calc - meas
743-
points.append({'x': float(item['q']), 'y': float(residual)})
750+
for point in self._get_aligned_analysis_values(experiment_index):
751+
sigma = point['sigma']
752+
residual = point['calculated'] - point['measured']
753+
if sigma > 0:
754+
residual = residual / sigma
755+
points.append({'x': point['q'], 'y': float(residual)})
744756
return points
745757
except Exception as e:
746758
console.debug(f'Error getting residual data points for index {experiment_index}: {e}')
747759
return []
748760

761+
def _get_residual_range(self) -> tuple[float, float, float, float]:
762+
"""Return residual plot ranges for the current selection."""
763+
try:
764+
if self.is_multi_experiment_mode:
765+
selected_indices = getattr(self._proxy._analysis, '_selected_experiment_indices', [])
766+
else:
767+
selected_indices = [self._project_lib.current_experiment_index]
768+
769+
all_points = []
770+
for experiment_index in selected_indices:
771+
all_points.extend(self.getResidualDataPoints(experiment_index))
772+
773+
if not all_points:
774+
return 0.0, 1.0, -1.0, 1.0
775+
776+
x_values = np.asarray([point['x'] for point in all_points], dtype=float)
777+
y_values = np.asarray([point['y'] for point in all_points], dtype=float)
778+
if x_values.size == 0 or y_values.size == 0:
779+
return 0.0, 1.0, -1.0, 1.0
780+
781+
min_x = float(np.min(x_values))
782+
max_x = float(np.max(x_values))
783+
min_y = float(np.min(y_values))
784+
max_y = float(np.max(y_values))
785+
786+
if min_y == max_y:
787+
margin = max(abs(min_y) * 0.05, 1.0)
788+
else:
789+
margin = (max_y - min_y) * 0.05
790+
791+
return min_x, max_x, min_y - margin, max_y + margin
792+
except Exception as e:
793+
console.debug(f'Error getting residual range: {e}')
794+
return 0.0, 1.0, -1.0, 1.0
795+
749796
def refreshSamplePage(self):
750797
# Clear cached data so it gets recalculated
751798
self._sample_data = {}
@@ -763,7 +810,6 @@ def refreshAnalysisPage(self):
763810
self._model_data = {}
764811
self._invalidate_residual_range_cache()
765812
self.drawCalculatedAndMeasuredOnAnalysisChart()
766-
# Notify the residual chart to re-poll data and ranges
767813
self.sampleChartRangesChanged.emit()
768814

769815
def refreshExperimentRanges(self):

EasyReflectometryApp/Backends/Py/py_backend.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def plottingGetAnalysisDataPoints(self, experiment_index: int) -> list:
137137

138138
@Slot(int, result='QVariantList')
139139
def plottingGetResidualDataPoints(self, experiment_index: int) -> list:
140-
"""Get residual data points (model - experiment) for a specific experiment."""
140+
"""Get residual data points for a specific experiment for residual plotting."""
141141
return self._plotting_1d.getResidualDataPoints(experiment_index)
142142

143143
######### Connections to relay info between the backend parts
@@ -165,7 +165,8 @@ def _connect_sample_page(self) -> None:
165165
def _connect_experiment_page(self) -> None:
166166
self._experiment.externalExperimentChanged.connect(self._relay_experiment_page_experiment_changed)
167167
self._experiment.externalExperimentChanged.connect(self._refresh_plots)
168-
self._experiment.qRangeUpdated.connect(self._sample.qRangeChanged)
168+
if hasattr(self._experiment, 'qRangeUpdated') and hasattr(self._sample, 'qRangeChanged'):
169+
self._experiment.qRangeUpdated.connect(self._sample.qRangeChanged)
169170

170171
def _connect_analysis_page(self) -> None:
171172
self._analysis.externalMinimizerChanged.connect(self._relay_analysis_page)

EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ EaElements.Dialog {
4545

4646
EaElements.Label {
4747
visible: Globals.BackendWrapper.analysisFitSuccess
48-
text: "Chi2: " + Globals.BackendWrapper.analysisFitChi2.toFixed(4)
48+
text: "Reduced Chi2: " + Globals.BackendWrapper.analysisFitChi2.toFixed(4)
4949
}
5050

5151
EaElements.Label {

EasyReflectometryApp/Gui/StatusBar.qml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ EaElements.StatusBar {
5656
EaElements.StatusBarItem {
5757
visible: Globals.BackendWrapper.analysisFitChi2 > 0
5858
keyIcon: 'chart-line'
59-
keyText: qsTr('Chi²')
59+
keyText: qsTr('Reduced Chi²')
6060
valueText: Globals.BackendWrapper.analysisFitChi2.toFixed(2)
61-
ToolTip.text: qsTr('Goodness of fit (chi-squared)')
61+
ToolTip.text: qsTr('Goodness of fit (reduced chi-squared)')
6262
}
6363
}

tests/test_logic_fitting.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def test_on_fit_finished_and_fit_properties_cover_multi_and_single_results():
100100
logic.on_fit_finished(make_fit_result(success=False, chi2=9.0, n_pars=1, x=[1, 2], reduced_chi=4.5))
101101
assert logic.fit_success is False
102102
assert logic.fit_n_pars == 1
103-
assert logic.fit_chi2 == 9.0
103+
assert logic.fit_chi2 == 4.5
104104

105105

106106
def test_fit_failure_and_cancellation_state_transitions():

0 commit comments

Comments
 (0)