Skip to content

Commit 37ffbc5

Browse files
committed
merged develop + minor fixes
1 parent 0615105 commit 37ffbc5

File tree

9 files changed

+184
-77
lines changed

9 files changed

+184
-77
lines changed

EasyReflectometryApp/Backends/Py/experiment.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,13 @@ def load(self, paths: str) -> None:
6464
for path in paths:
6565
generalized = IO.generalizePath(path)
6666
if self._project_logic.count_datasets_in_file(generalized) > 1:
67-
_count, changed = self._project_logic.load_all_experiments_from_file(generalized)
67+
result = self._project_logic.load_all_experiments_from_file(generalized)
68+
if isinstance(result, tuple):
69+
_count, changed = result
70+
else:
71+
changed = bool(getattr(self._project_logic, '_last_q_range_changed', False))
6872
else:
69-
changed = self._project_logic.load_new_experiment(generalized)
73+
changed = bool(self._project_logic.load_new_experiment(generalized))
7074
if changed:
7175
q_range_changed = True
7276
self.experimentChanged.emit()

EasyReflectometryApp/Backends/Py/logic/calculators.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ 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]
20+
if hasattr(self._project_lib._calculator, 'switch'):
21+
self._project_lib._calculator.switch(new_calculator)
2022
self._project_lib.calculator = new_calculator
2123
return True
2224
return False

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 self._results[0].n_pars
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: 4 additions & 3 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

@@ -119,10 +120,10 @@ def load_new_experiment(self, path: str) -> bool:
119120
def count_datasets_in_file(self, path: str) -> int:
120121
return self._project_lib.count_datasets_in_file(path)
121122

122-
def load_all_experiments_from_file(self, path: str) -> tuple[int, bool]:
123+
def load_all_experiments_from_file(self, path: str) -> int:
123124
loaded_count = self._project_lib.load_all_experiments_from_file(path)
124-
q_max_changed = self._sync_q_max_with_loaded_experiments()
125-
return loaded_count, q_max_changed
125+
self._last_q_range_changed = self._sync_q_max_with_loaded_experiments()
126+
return loaded_count
126127

127128
def _sync_q_max_with_loaded_experiments(self) -> bool:
128129
"""Set model q_max to the largest q value found in loaded experiments.

EasyReflectometryApp/Backends/Py/plotting_1d.py

Lines changed: 144 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,22 @@ def individualExperimentDataList(self) -> list:
459459
)
460460
return qml_data_list
461461

462+
@Property(float, notify=sampleChartRangesChanged)
463+
def residualMinX(self):
464+
return self._get_residual_range()[0]
465+
466+
@Property(float, notify=sampleChartRangesChanged)
467+
def residualMaxX(self):
468+
return self._get_residual_range()[1]
469+
470+
@Property(float, notify=sampleChartRangesChanged)
471+
def residualMinY(self):
472+
return self._get_residual_range()[2]
473+
474+
@Property(float, notify=sampleChartRangesChanged)
475+
def residualMaxY(self):
476+
return self._get_residual_range()[3]
477+
462478
@Slot(str, str, 'QVariant')
463479
def setQtChartsSerieRef(self, page: str, serie: str, ref: QObject):
464480
self._chartRefs['QtCharts'][page][serie] = ref
@@ -537,70 +553,143 @@ def getExperimentDataPoints(self, experiment_index: int) -> list:
537553
console.debug(f'Error getting experiment data points for index {experiment_index}: {e}')
538554
return []
539555

540-
@Slot(int, result='QVariantList')
541-
def getAnalysisDataPoints(self, experiment_index: int) -> list:
542-
"""Get measured and calculated data points for a specific experiment for analysis plotting."""
543-
try:
544-
# Get measured experimental data
545-
exp_data = self._project_lib.experimental_data_for_model_at_index(experiment_index)
546-
547-
# Get the model index for this experiment - it may be different from experiment_index
548-
# When multiple experiments share the same model
549-
model_index = 0
550-
model_found = False
551-
if hasattr(exp_data, 'model') and exp_data.model is not None:
552-
# Find the model index in the models collection
553-
for idx, model in enumerate(self._project_lib.models):
554-
if model is exp_data.model:
555-
model_index = idx
556-
model_found = True
557-
break
558-
if not model_found:
559-
console.debug(f'Warning: model for experiment {experiment_index} '
560-
f'not found in models collection, falling back to model 0')
561-
else:
562-
# Fallback: use experiment_index if it's within model range, else 0
563-
model_index = experiment_index if experiment_index < len(self._project_lib.models) else 0
556+
def _get_experiment_model_index(self, experiment_index: int, exp_data=None) -> int:
557+
"""Resolve the model index used by a given experiment."""
558+
if exp_data is not None and hasattr(exp_data, 'model') and exp_data.model is not None:
559+
for idx, model in enumerate(self._project_lib.models):
560+
if model is exp_data.model:
561+
return idx
562+
if experiment_index < len(self._project_lib.models):
563+
return experiment_index
564+
return 0
565+
566+
def _get_aligned_analysis_values(self, experiment_index: int) -> list[dict]:
567+
"""Return measured, calculated and sigma values aligned on experiment q points."""
568+
exp_data = self._project_lib.experimental_data_for_model_at_index(experiment_index)
569+
q_values = np.asarray(getattr(exp_data, 'x', np.empty(0)), dtype=float)
570+
measured_values = np.asarray(getattr(exp_data, 'y', np.empty(0)), dtype=float)
571+
sigma_values = np.asarray(getattr(exp_data, 'ye', np.zeros_like(measured_values)), dtype=float)
572+
573+
if q_values.size == 0 or measured_values.size == 0:
574+
return []
564575

565-
# Get the q values from the experimental data for calculating the model
566-
q_values = exp_data.x
567-
# Filter to q range
568-
mask = (q_values >= self._project_lib.q_min) & (q_values <= self._project_lib.q_max)
569-
q_filtered = q_values[mask]
576+
q_mask = (q_values >= self._project_lib.q_min) & (q_values <= self._project_lib.q_max)
577+
q_filtered = q_values[q_mask]
578+
measured_filtered = measured_values[q_mask]
579+
sigma_filtered = sigma_values[q_mask] if sigma_values.size else np.zeros_like(measured_filtered)
570580

571-
# Get calculated model data at the same q points using the correct model index
581+
model_index = self._get_experiment_model_index(experiment_index, exp_data)
582+
try:
572583
calc_data = self._project_lib.model_data_for_model_at_index(model_index, q_filtered)
584+
except TypeError:
585+
calc_data = self._project_lib.model_data_for_model_at_index(model_index)
586+
587+
calc_values = np.asarray(getattr(calc_data, 'y', np.empty(0)), dtype=float)
588+
calc_q_values = np.asarray(getattr(calc_data, 'x', np.empty(0)), dtype=float)
589+
590+
if calc_values.size == q_filtered.size:
591+
calculated_filtered = calc_values
592+
elif calc_values.size == 0:
593+
calculated_filtered = measured_filtered.copy()
594+
elif calc_q_values.size == calc_values.size and calc_values.size > 1:
595+
calculated_filtered = np.interp(q_filtered, calc_q_values, calc_values)
596+
elif calc_values.size == 1:
597+
calculated_filtered = np.full_like(measured_filtered, calc_values[0], dtype=float)
598+
else:
599+
calculated_filtered = np.resize(calc_values, q_filtered.size)
600+
601+
measured_filtered = self._apply_rq4(q_filtered, measured_filtered)
602+
calculated_filtered = self._apply_rq4(q_filtered, calculated_filtered)
603+
sigma_filtered = self._apply_rq4(q_filtered, sigma_filtered)
604+
605+
points = []
606+
for q_value, measured_value, calculated_value, sigma_value in zip(
607+
q_filtered,
608+
measured_filtered,
609+
calculated_filtered,
610+
sigma_filtered,
611+
):
612+
points.append(
613+
{
614+
'q': float(q_value),
615+
'measured': float(measured_value),
616+
'calculated': float(calculated_value),
617+
'sigma': float(sigma_value),
618+
}
619+
)
620+
return points
573621

622+
@Slot(int, result='QVariantList')
623+
def getAnalysisDataPoints(self, experiment_index: int) -> list:
624+
"""Get measured and calculated data points for a specific experiment for analysis plotting."""
625+
try:
574626
points = []
575-
exp_points = list(exp_data.data_points())
576-
calc_y = calc_data.y
577-
578-
if len(calc_y) != len(q_filtered):
579-
console.debug(f'Warning: calculated data length ({len(calc_y)}) '
580-
f'differs from filtered experimental data ({len(q_filtered)}) '
581-
f'for experiment {experiment_index}')
582-
583-
calc_idx = 0
584-
for point in exp_points:
585-
if point[0] < self._project_lib.q_max and self._project_lib.q_min < point[0]:
586-
q = point[0]
587-
r_meas = point[1]
588-
calc_y_val = calc_y[calc_idx] if calc_idx < len(calc_y) else r_meas
589-
r_meas = self._apply_rq4(q, r_meas)
590-
calc_y_val = self._apply_rq4(q, calc_y_val)
591-
points.append(
592-
{
593-
'x': float(q),
594-
'measured': float(np.log10(r_meas)),
595-
'calculated': float(np.log10(calc_y_val)),
596-
}
597-
)
598-
calc_idx += 1
627+
for point in self._get_aligned_analysis_values(experiment_index):
628+
measured = point['measured']
629+
calculated = point['calculated']
630+
points.append(
631+
{
632+
'x': point['q'],
633+
'measured': float(np.log10(measured)) if measured > 0 else -10.0,
634+
'calculated': float(np.log10(calculated)) if calculated > 0 else -10.0,
635+
}
636+
)
599637
return points
600638
except Exception as e:
601639
console.debug(f'Error getting analysis data points for index {experiment_index}: {e}')
602640
return []
603641

642+
@Slot(int, result='QVariantList')
643+
def getResidualDataPoints(self, experiment_index: int) -> list:
644+
"""Get residual data points for a specific experiment."""
645+
try:
646+
points = []
647+
for point in self._get_aligned_analysis_values(experiment_index):
648+
sigma = point['sigma']
649+
residual = point['calculated'] - point['measured']
650+
if sigma > 0:
651+
residual = residual / sigma
652+
points.append({'x': point['q'], 'y': float(residual)})
653+
return points
654+
except Exception as e:
655+
console.debug(f'Error getting residual data points for index {experiment_index}: {e}')
656+
return []
657+
658+
def _get_residual_range(self) -> tuple[float, float, float, float]:
659+
"""Return residual plot ranges for the current selection."""
660+
try:
661+
if self.is_multi_experiment_mode:
662+
selected_indices = getattr(self._proxy._analysis, '_selected_experiment_indices', [])
663+
else:
664+
selected_indices = [self._project_lib.current_experiment_index]
665+
666+
all_points = []
667+
for experiment_index in selected_indices:
668+
all_points.extend(self.getResidualDataPoints(experiment_index))
669+
670+
if not all_points:
671+
return 0.0, 1.0, -1.0, 1.0
672+
673+
x_values = np.asarray([point['x'] for point in all_points], dtype=float)
674+
y_values = np.asarray([point['y'] for point in all_points], dtype=float)
675+
if x_values.size == 0 or y_values.size == 0:
676+
return 0.0, 1.0, -1.0, 1.0
677+
678+
min_x = float(np.min(x_values))
679+
max_x = float(np.max(x_values))
680+
min_y = float(np.min(y_values))
681+
max_y = float(np.max(y_values))
682+
683+
if min_y == max_y:
684+
margin = max(abs(min_y) * 0.05, 1.0)
685+
else:
686+
margin = (max_y - min_y) * 0.05
687+
688+
return min_x, max_x, min_y - margin, max_y + margin
689+
except Exception as e:
690+
console.debug(f'Error getting residual range: {e}')
691+
return 0.0, 1.0, -1.0, 1.0
692+
604693
def refreshSamplePage(self):
605694
# Clear cached data so it gets recalculated
606695
self._sample_data = {}
@@ -617,6 +706,7 @@ def refreshExperimentPage(self):
617706
def refreshAnalysisPage(self):
618707
self._model_data = {}
619708
self.drawCalculatedAndMeasuredOnAnalysisChart()
709+
self.sampleChartRangesChanged.emit()
620710

621711
def refreshExperimentRanges(self):
622712
"""Emit signal to update experiment chart ranges when selection changes."""

EasyReflectometryApp/Backends/Py/py_backend.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ def plottingGetAnalysisDataPoints(self, experiment_index: int) -> list:
135135
"""Get measured and calculated data points for a specific experiment for analysis plotting."""
136136
return self._plotting_1d.getAnalysisDataPoints(experiment_index)
137137

138+
@Slot(int, result='QVariantList')
139+
def plottingGetResidualDataPoints(self, experiment_index: int) -> list:
140+
"""Get residual data points for a specific experiment for residual plotting."""
141+
return self._plotting_1d.getResidualDataPoints(experiment_index)
142+
138143
######### Connections to relay info between the backend parts
139144
def _connect_backend_parts(self) -> None:
140145
self._connect_project_page()
@@ -160,7 +165,8 @@ def _connect_sample_page(self) -> None:
160165
def _connect_experiment_page(self) -> None:
161166
self._experiment.externalExperimentChanged.connect(self._relay_experiment_page_experiment_changed)
162167
self._experiment.externalExperimentChanged.connect(self._refresh_plots)
163-
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)
164170

165171
def _connect_analysis_page(self) -> None:
166172
self._analysis.externalMinimizerChanged.connect(self._relay_analysis_page)
@@ -196,12 +202,10 @@ def _relay_project_page_project_changed(self):
196202
self._sample.assembliesIndexChanged.emit()
197203
self._experiment.experimentChanged.emit()
198204
self._analysis.experimentsChanged.emit()
199-
self._analysis._clearCacheAndEmitParametersChanged()
200205
self._status.statusChanged.emit()
201206
self._summary.summaryChanged.emit()
202207
self._plotting_1d.reset_data()
203208
self._refresh_plots()
204-
self._plotting_1d.samplePageResetAxes.emit()
205209

206210
def _relay_sample_page_sample_changed(self):
207211
self._plotting_1d.reset_data()

0 commit comments

Comments
 (0)