Skip to content

Commit 7b209a1

Browse files
committed
more changes to how resituals are presented
1 parent 097110c commit 7b209a1

11 files changed

Lines changed: 429 additions & 27 deletions

File tree

EasyReflectometryApp/Backends/Py/logic/fitting.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,14 +226,14 @@ def on_fit_finished(self, results: List[FitResults]) -> None:
226226
def fit_n_pars(self) -> int:
227227
"""Return the global number of refined parameters for the fit."""
228228
if self._results:
229-
return self._results[0].n_pars
229+
return sum(r.n_pars for r in self._results)
230230
if self._result is None:
231231
return 0
232232
return self._result.n_pars
233233

234234
@property
235235
def fit_chi2(self) -> float:
236-
"""Return reduced chi-squared across all fits (chi2 / degrees of freedom)."""
236+
"""Return reduced chi-squared across all fits (chi2 / degrees of freedom)"""
237237
if self._results:
238238
try:
239239
total_chi2 = float(sum(r.chi2 for r in self._results))

EasyReflectometryApp/Backends/Py/plotting_1d.py

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,31 @@ def _apply_rq4(self, x, y):
7676
return y * (x**4)
7777
return y
7878

79+
def _qtcharts_series_ref(self, page: str, serie: str):
80+
return self._chartRefs['QtCharts'].get(page, {}).get(serie)
81+
82+
def _clear_qtcharts_series(self, page: str, *series_names: str) -> bool:
83+
missing_series = []
84+
for series_name in series_names:
85+
series_ref = self._qtcharts_series_ref(page, series_name)
86+
if series_ref is None:
87+
missing_series.append(series_name)
88+
continue
89+
series_ref.clear()
90+
91+
if missing_series:
92+
console.debug(
93+
IO.formatMsg(
94+
'sub',
95+
f'{page} series unavailable',
96+
', '.join(missing_series),
97+
'skipping redraw',
98+
)
99+
)
100+
return False
101+
102+
return True
103+
79104
# R(q)×q⁴ mode
80105
@Property(bool, notify=plotModeChanged)
81106
def plotRQ4(self) -> bool:
@@ -779,12 +804,12 @@ def drawMeasuredOnExperimentChart(self):
779804
self.qtchartsReplaceMeasuredOnExperimentChartAndRedraw()
780805

781806
def qtchartsReplaceMeasuredOnExperimentChartAndRedraw(self):
782-
series_measured = self._chartRefs['QtCharts']['experimentPage']['measuredSerie']
783-
series_measured.clear()
784-
series_error_upper = self._chartRefs['QtCharts']['experimentPage']['errorUpperSerie']
785-
series_error_upper.clear()
786-
series_error_lower = self._chartRefs['QtCharts']['experimentPage']['errorLowerSerie']
787-
series_error_lower.clear()
807+
if not self._clear_qtcharts_series('experimentPage', 'measuredSerie', 'errorUpperSerie', 'errorLowerSerie'):
808+
return
809+
810+
series_measured = self._qtcharts_series_ref('experimentPage', 'measuredSerie')
811+
series_error_upper = self._qtcharts_series_ref('experimentPage', 'errorUpperSerie')
812+
series_error_lower = self._qtcharts_series_ref('experimentPage', 'errorLowerSerie')
788813
nr_points = 0
789814
for point in self.experiment_data.data_points():
790815
q = point[0]
@@ -808,12 +833,7 @@ def qtchartsReplaceMultiExperimentChartAndRedraw(self):
808833
console.debug(IO.formatMsg('sub', 'Multi-experiment mode', 'drawing separate lines'))
809834

810835
# Clear default series but don't use them for multi-experiment mode
811-
if 'measuredSerie' in self._chartRefs['QtCharts']['experimentPage']:
812-
self._chartRefs['QtCharts']['experimentPage']['measuredSerie'].clear()
813-
if 'errorUpperSerie' in self._chartRefs['QtCharts']['experimentPage']:
814-
self._chartRefs['QtCharts']['experimentPage']['errorUpperSerie'].clear()
815-
if 'errorLowerSerie' in self._chartRefs['QtCharts']['experimentPage']:
816-
self._chartRefs['QtCharts']['experimentPage']['errorLowerSerie'].clear()
836+
self._clear_qtcharts_series('experimentPage', 'measuredSerie', 'errorUpperSerie', 'errorLowerSerie')
817837

818838
# Individual experiment series are managed by QML
819839
# This method is called to trigger the refresh, actual drawing is handled by QML
@@ -832,20 +852,18 @@ def qtchartsReplaceMultiExperimentAnalysisChartAndRedraw(self):
832852
console.debug(IO.formatMsg('sub', 'Multi-experiment mode', 'drawing separate lines on analysis page'))
833853

834854
# Clear default series but don't use them for multi-experiment mode
835-
if 'measuredSerie' in self._chartRefs['QtCharts']['analysisPage']:
836-
self._chartRefs['QtCharts']['analysisPage']['measuredSerie'].clear()
837-
if 'calculatedSerie' in self._chartRefs['QtCharts']['analysisPage']:
838-
self._chartRefs['QtCharts']['analysisPage']['calculatedSerie'].clear()
855+
self._clear_qtcharts_series('analysisPage', 'measuredSerie', 'calculatedSerie')
839856

840857
# Individual experiment series are managed by QML
841858
# This method is called to trigger the refresh, actual drawing is handled by QML
842859
self.experimentDataChanged.emit()
843860

844861
def qtchartsReplaceCalculatedAndMeasuredOnAnalysisChartAndRedraw(self):
845-
series_measured = self._chartRefs['QtCharts']['analysisPage']['measuredSerie']
846-
series_measured.clear()
847-
series_calculated = self._chartRefs['QtCharts']['analysisPage']['calculatedSerie']
848-
series_calculated.clear()
862+
if not self._clear_qtcharts_series('analysisPage', 'measuredSerie', 'calculatedSerie'):
863+
return
864+
865+
series_measured = self._qtcharts_series_ref('analysisPage', 'measuredSerie')
866+
series_calculated = self._qtcharts_series_ref('analysisPage', 'calculatedSerie')
849867
nr_points = 0
850868
for point in self.experiment_data.data_points():
851869
q = point[0]

EasyReflectometryApp/Backends/Py/py_backend.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,12 +201,10 @@ def _relay_project_page_project_changed(self):
201201
self._sample.assembliesIndexChanged.emit()
202202
self._experiment.experimentChanged.emit()
203203
self._analysis.experimentsChanged.emit()
204-
self._analysis._clearCacheAndEmitParametersChanged()
205204
self._status.statusChanged.emit()
206205
self._summary.summaryChanged.emit()
207206
self._plotting_1d.reset_data()
208207
self._refresh_plots()
209-
self._plotting_1d.samplePageResetAxes.emit()
210208

211209
def _relay_sample_page_sample_changed(self):
212210
self._plotting_1d.reset_data()

EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ Rectangle {
424424
Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage',
425425
'calculatedSerie',
426426
calculated)
427+
Globals.BackendWrapper.plottingRefreshAnalysis()
427428

428429
// Initialize multi-experiment support
429430
updateMultiExperimentSeries()

EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,7 @@ Rectangle {
445445
Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage',
446446
'calculatedSerie',
447447
calculated)
448+
Globals.BackendWrapper.plottingRefreshAnalysis()
448449

449450
// Initialize multi-experiment support
450451
updateMultiExperimentSeries()

tests/factories.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,15 @@ def __init__(self, success=True, chi2=1.0, n_pars=1, x=None, reduced_chi=0.5, mi
302302

303303

304304
class FakeProject:
305+
@property
306+
def calculator(self):
307+
return self._calculator_name
308+
309+
@calculator.setter
310+
def calculator(self, value):
311+
self._calculator_name = value
312+
self._calculator.switched_to = value
313+
305314
@property
306315
def _current_model_index(self):
307316
return self.current_model_index
@@ -330,7 +339,7 @@ def __init__(
330339
self.current_assembly_index = 0
331340
self.current_layer_index = 0
332341
self._calculator = FakeCalculatorController(calculator_interfaces or ['refnx', 'refl1d'])
333-
self.calculator = calculator_name
342+
self._calculator_name = calculator_name
334343
self.minimizer = FakeMinimizerValue(minimizer_name)
335344
self._fitter = None
336345
self.fitter = None

tests/test_logic_fitting.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def test_on_fit_finished_and_fit_properties_cover_multi_and_single_results():
9595
assert logic.fit_finished is True
9696
assert logic.fit_success is True
9797
assert logic.fit_n_pars == 4
98-
assert logic.fit_chi2 == 10.0
98+
assert logic.fit_chi2 == 2.0
9999

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

tests/test_logic_project.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def test_project_info_and_delegated_file_operations():
7272
logic.load_experiment('exp.ort')
7373
logic.load_new_experiment('new.ort')
7474
assert logic.count_datasets_in_file('file.ort') == 3
75-
assert logic.load_all_experiments_from_file('file.ort') == 2
75+
assert logic.load_all_experiments_from_file('file.ort') == (2, False)
7676

7777
assert ('create',) in project_lib.calls
7878
assert ('save_as_json', False) in project_lib.calls

tests/test_py_backend.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from types import SimpleNamespace
2+
from unittest.mock import MagicMock
23

34
from PySide6.QtCore import QObject
45
from PySide6.QtCore import Signal
@@ -34,6 +35,7 @@ class StubSample(QObject):
3435
modelsIndexChanged = Signal()
3536
assembliesTableChanged = Signal()
3637
assembliesIndexChanged = Signal()
38+
qRangeChanged = Signal()
3739

3840
def __init__(self, _project_lib):
3941
super().__init__()
@@ -46,6 +48,7 @@ def _clearCacheAndEmitLayersChanged(self):
4648
class StubExperiment(QObject):
4749
externalExperimentChanged = Signal()
4850
experimentChanged = Signal()
51+
qRangeUpdated = Signal()
4952

5053
def __init__(self, _project_lib):
5154
super().__init__()
@@ -211,3 +214,90 @@ def test_backend_refresh_plots_emits_ranges_and_multi_signal(monkeypatch, qcore_
211214
assert backend.plottingIndividualExperimentDataList == [{'name': 'E0', 'index': 0, 'color': '#111111', 'hasData': True}]
212215
assert backend.plottingGetExperimentDataPoints(3) == [{'x': 3.0, 'y': 0.0}]
213216
assert backend.plottingGetAnalysisDataPoints(5) == [{'x': 5.0, 'measured': 0.0, 'calculated': 0.0}]
217+
218+
219+
# ===========================================================================
220+
# Delegation-contract tests.
221+
# These verify that PyBackend correctly forwards calls to Plotting1d without
222+
# requiring the full Qt infrastructure — Plotting1d is replaced by MagicMock.
223+
# ===========================================================================
224+
225+
class _DelegationStub:
226+
"""Minimal replica of the PyBackend delegation methods under test."""
227+
228+
def __init__(self, plotting_1d):
229+
self._plotting_1d = plotting_1d
230+
231+
def plottingGetAnalysisDataPoints(self, experiment_index: int) -> list:
232+
return self._plotting_1d.getAnalysisDataPoints(experiment_index)
233+
234+
def plottingGetResidualDataPoints(self, experiment_index: int) -> list:
235+
return self._plotting_1d.getResidualDataPoints(experiment_index)
236+
237+
238+
class TestPlottingGetResidualDataPointsDelegation:
239+
def _backend(self):
240+
mock_plotting = MagicMock()
241+
return _DelegationStub(mock_plotting), mock_plotting
242+
243+
def test_delegates_to_plotting_1d(self):
244+
backend, plotting = self._backend()
245+
expected = [{'x': 0.1, 'y': 0.002}, {'x': 0.2, 'y': -0.001}]
246+
plotting.getResidualDataPoints.return_value = expected
247+
248+
result = backend.plottingGetResidualDataPoints(0)
249+
250+
plotting.getResidualDataPoints.assert_called_once_with(0)
251+
assert result == expected
252+
253+
def test_passes_experiment_index(self):
254+
backend, plotting = self._backend()
255+
plotting.getResidualDataPoints.return_value = []
256+
257+
backend.plottingGetResidualDataPoints(3)
258+
259+
plotting.getResidualDataPoints.assert_called_once_with(3)
260+
261+
def test_returns_empty_list_when_plotting_returns_empty(self):
262+
backend, plotting = self._backend()
263+
plotting.getResidualDataPoints.return_value = []
264+
265+
result = backend.plottingGetResidualDataPoints(0)
266+
267+
assert result == []
268+
269+
def test_returns_result_unchanged(self):
270+
backend, plotting = self._backend()
271+
payload = [{'x': i * 0.1, 'y': i * 0.001} for i in range(10)]
272+
plotting.getResidualDataPoints.return_value = payload
273+
274+
result = backend.plottingGetResidualDataPoints(0)
275+
276+
assert result is payload
277+
278+
279+
class TestPlottingGetAnalysisDataPointsDelegation:
280+
"""Regression: existing analysis bridging is unaffected by residual additions."""
281+
282+
def _backend(self):
283+
mock_plotting = MagicMock()
284+
return _DelegationStub(mock_plotting), mock_plotting
285+
286+
def test_delegates_to_plotting_1d(self):
287+
backend, plotting = self._backend()
288+
expected = [{'x': 0.1, 'measured': -2.0, 'calculated': -1.9}]
289+
plotting.getAnalysisDataPoints.return_value = expected
290+
291+
result = backend.plottingGetAnalysisDataPoints(0)
292+
293+
plotting.getAnalysisDataPoints.assert_called_once_with(0)
294+
assert result == expected
295+
296+
def test_passes_experiment_index(self):
297+
backend, plotting = self._backend()
298+
plotting.getAnalysisDataPoints.return_value = []
299+
300+
backend.plottingGetAnalysisDataPoints(5)
301+
302+
plotting.getAnalysisDataPoints.assert_called_once_with(5)
303+

tests/test_py_experiment.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def count_datasets_in_file(self, path):
3333

3434
def load_all_experiments_from_file(self, path):
3535
self.loaded_all.append(path)
36+
return (2, False)
3637

3738
def load_new_experiment(self, path):
3839
self.loaded_new.append(path)

0 commit comments

Comments
 (0)