Skip to content

Commit b7d88bf

Browse files
committed
CR fixes
1 parent fa01488 commit b7d88bf

6 files changed

Lines changed: 194 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@
144144
colour. A channel that cannot be calculated (for example spin-flip on a
145145
non-magnetic model) is shown as measured data only.
146146
- Each channel is fitted, drawn and reported with the resolution of its own
147-
file (see "Variable resolution" below).
147+
file (see "Variable resolution" above).
148148
- **Limitation:** Bayesian sampling of polarized experiments is not
149149
supported yet.
150150
- Project save/load now fully supports polarized experiments.

‎EasyReflectometryApp/Backends/Py/experiment.py‎

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from easyreflectometry import Project as ProjectLib
66
from PySide6.QtCore import Property
77
from PySide6.QtCore import QObject
8+
from PySide6.QtCore import QUrl
89
from PySide6.QtCore import Signal
910
from PySide6.QtCore import Slot
1011
from PySide6.QtQml import QJSValue
@@ -21,6 +22,37 @@ def _from_qml(value):
2122
return value
2223

2324

25+
def _local_path(path) -> str:
26+
"""A local file path from a QML file-dialog entry (`QUrl`), a file URL or a path.
27+
28+
`str()` of a `QUrl` is its Python repr, and `generalizePath` does not undo
29+
percent-encoding (spaces, non-ASCII names), so URLs go through Qt.
30+
"""
31+
if isinstance(path, QUrl):
32+
return path.toLocalFile()
33+
path = str(path)
34+
if path.startswith('file:'):
35+
return QUrl(path).toLocalFile()
36+
return IO.generalizePath(path)
37+
38+
39+
# The library's caveat for a file without a resolution column merged with
40+
# files that have one, in the words of the user guide.
41+
_NO_RESOLUTION_WARNING = 'carry no q-resolution'
42+
_NO_RESOLUTION_MESSAGE = (
43+
'the file has no q-resolution column while the other data of this experiment do; '
44+
'its points are given a percentage (dq/q) resolution instead.'
45+
)
46+
47+
48+
def _user_warning_text(message) -> str:
49+
"""The library's warning text, reworded for the dialog where it names internals."""
50+
text = str(message)
51+
if _NO_RESOLUTION_WARNING in text:
52+
return _NO_RESOLUTION_MESSAGE
53+
return text
54+
55+
2456
# Spin channels accepted by the polarized import, in canonical order.
2557
_CHANNELS = ('pp', 'pm', 'mp', 'mm')
2658

@@ -118,40 +150,67 @@ def appendToCurrent(self, paths) -> None:
118150
are merged into the current experiment and keep their own resolution,
119151
so the fit smears each point with the width it was measured with.
120152
153+
`paths` is a list of `QUrl` (a QML file dialog's `selectedFiles`), file
154+
URLs or local paths.
155+
121156
A file that cannot be added (a polarized experiment, a file holding
122-
several datasets, a parse error) is reported through `loadFailed` and
123-
skipped; the others are still added. A file without a resolution column
124-
mixed with files that have one is added with an assumed resolution and
125-
reported through `loadWarning`.
157+
several datasets, a parse error) is skipped; the others are still added.
158+
A file without a resolution column mixed with files that have one is
159+
added with an assumed resolution. All refusals and caveats of one call
160+
are reported together: through `loadFailed` when any file was refused,
161+
otherwise through `loadWarning`.
126162
"""
127163
paths = _from_qml(paths)
128-
if isinstance(paths, str):
129-
paths = paths.split(',')
164+
if isinstance(paths, (str, QUrl)):
165+
paths = paths.split(',') if isinstance(paths, str) else [paths]
130166

167+
failures = []
168+
caveats = []
131169
q_range_changed = False
132170
for path in paths:
133-
if not path:
171+
if not path or (isinstance(path, QUrl) and path.isEmpty()):
134172
continue
135-
generalized = IO.generalizePath(str(path))
173+
generalized = _local_path(path)
136174
name = os.path.basename(generalized)
175+
# catch_warnings swaps the process-wide warning state; the Experimental
176+
# data group is disabled while a fit runs, so no worker thread is
177+
# warning concurrently when this is reached from the GUI.
137178
try:
138179
with warnings.catch_warnings(record=True) as caught:
139180
warnings.simplefilter('always')
140181
changed = self._project_logic.append_to_current_experiment(generalized)
141182
except Exception as error: # noqa: BLE001 - any refusal reaches the user, not the event loop
142183
console.error(f'Adding {generalized} to the current experiment failed: {error}')
143-
self.loadFailed.emit(f'{name}: {error}')
184+
failures.append(f'{name}: {error}')
144185
continue
145186
for caught_warning in caught:
146187
if issubclass(caught_warning.category, UserWarning):
147188
console.info(f'{name}: {caught_warning.message}')
148-
self.loadWarning.emit(f'{name}: {caught_warning.message}')
189+
caveats.append(f'{name}: {_user_warning_text(caught_warning.message)}')
190+
else:
191+
# Not meant for the user (e.g. deprecations): hand it back to
192+
# the normal warning machinery instead of swallowing it.
193+
warnings.warn_explicit(
194+
caught_warning.message,
195+
caught_warning.category,
196+
caught_warning.filename,
197+
caught_warning.lineno,
198+
)
149199
if changed:
150200
q_range_changed = True
151201
self.experimentChanged.emit()
152202
self.externalExperimentChanged.emit()
153203
if q_range_changed:
154204
self.qRangeUpdated.emit()
205+
# One message per call: the GUI shows a single dialog, so per-file
206+
# emissions would leave only the last one visible.
207+
if failures:
208+
message = '\n'.join(failures)
209+
if caveats:
210+
message += '\n\nAdded with a warning:\n' + '\n'.join(caveats)
211+
self.loadFailed.emit(message)
212+
elif caveats:
213+
self.loadWarning.emit('\n'.join(caveats))
155214

156215
@Slot('QVariant', result='QVariantList')
157216
def suggestPolarizedChannels(self, paths) -> list:

‎EasyReflectometryApp/Backends/Py/logic/fitting.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,8 @@ def prepare_threaded_sample(self, minimizers_logic: 'Minimizers') -> 'MultiFitte
450450
:return: The fitter, or None when sampling cannot start.
451451
"""
452452
try:
453+
# `prepare_sample` refuses the same cases (FitScopeError, reported
454+
# below); these checks stay for the GUI wording ("tick Fit ...").
453455
if not self._ordered_experiments():
454456
self._fail_before_start('No experiments to sample')
455457
return None

‎EasyReflectometryApp/Backends/Py/plotting_1d.py‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,6 +1514,10 @@ def _get_aligned_analysis_values(self, experiment_index: int, channel: str = '')
15141514
# The curve the fit compares these points to: smeared with the
15151515
# resolution the dataset (or spin channel) was measured with, not the
15161516
# model's. They differ for every dataset of a model but the first.
1517+
# Evaluated on all measured q and masked afterwards: a per-point
1518+
# (Pointwise) resolution is exact only on its own q array, and a merged
1519+
# dataset can hold one q twice with different widths, which a subset
1520+
# would interpolate to the same width.
15171521
resolution_function = getattr(exp_data, 'resolution_function', None)
15181522
if model_index is None:
15191523
calc_data = None
@@ -1523,21 +1527,21 @@ def _get_aligned_analysis_values(self, experiment_index: int, channel: str = '')
15231527
# the measured points alone rather than another channel's curve.
15241528
try:
15251529
calc_data = self._project_lib.model_data_for_model_at_index(
1526-
model_index, q_filtered, channel=channel, resolution_function=resolution_function
1530+
model_index, q_values, channel=channel, resolution_function=resolution_function
15271531
)
15281532
except Exception as exception: # noqa: BLE001 - any backend refusal means "no curve"
15291533
console.debug(f'No calculated curve for channel {channel}: {exception}')
15301534
calc_data = None
15311535
else:
15321536
calc_data = self._project_lib.model_data_for_model_at_index(
1533-
model_index, q_filtered, resolution_function=resolution_function
1537+
model_index, q_values, resolution_function=resolution_function
15341538
)
15351539

15361540
calc_values = np.asarray(getattr(calc_data, 'y', np.empty(0)), dtype=float)
15371541
calc_q_values = np.asarray(getattr(calc_data, 'x', np.empty(0)), dtype=float)
15381542
has_calculated = True
1539-
if calc_values.size == q_filtered.size and calc_values.size > 0:
1540-
calculated_filtered = calc_values
1543+
if calc_values.size == q_values.size and calc_values.size > 0:
1544+
calculated_filtered = calc_values[q_mask]
15411545
elif calc_q_values.size == calc_values.size and calc_values.size > 1:
15421546
# Calculated on its own q grid: interpolate onto the measured q.
15431547
calculated_filtered = np.interp(q_filtered, calc_q_values, calc_values)
@@ -1547,9 +1551,9 @@ def _get_aligned_analysis_values(self, experiment_index: int, channel: str = '')
15471551
# report "no curve" so plots and residuals leave it out.
15481552
if calc_values.size:
15491553
self._warn_once(
1550-
('misaligned', experiment_index, channel, calc_values.size, q_filtered.size),
1554+
('misaligned', experiment_index, channel, calc_values.size, q_values.size),
15511555
f'Calculated curve for experiment {experiment_index} has {calc_values.size} values '
1552-
f'for {q_filtered.size} measured points and no q to align them; it is not shown.',
1556+
f'for {q_values.size} measured points and no q to align them; it is not shown.',
15531557
)
15541558
has_calculated = False
15551559
calculated_filtered = np.full(q_filtered.size, np.nan)

‎EasyReflectometryApp/Gui/ApplicationWindow.qml‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -383,14 +383,22 @@ EaComponents.ApplicationWindow {
383383
enabled: target !== null
384384
ignoreUnknownSignals: true
385385
function onLoadFailed(message) {
386-
engineRejectionDialog.title = qsTr("Cannot import the data")
387-
engineRejectionDialog.message = message
388-
engineRejectionDialog.open()
386+
showLoadMessage(qsTr("Cannot import the data"), message)
389387
}
390388
// Imported, but with an assumption the user should know about (e.g. a
391389
// file without a resolution column merged with files that have one).
392390
function onLoadWarning(message) {
393-
engineRejectionDialog.title = qsTr("Data imported with a warning")
391+
showLoadMessage(qsTr("Data imported with a warning"), message)
392+
}
393+
// An import of several files reports file by file: add to a dialog
394+
// that is still open rather than replacing what it shows, and keep
395+
// its first title (a refusal outranks a later warning).
396+
function showLoadMessage(title, message) {
397+
if (engineRejectionDialog.visible) {
398+
engineRejectionDialog.message += '\n\n' + message
399+
return
400+
}
401+
engineRejectionDialog.title = title
394402
engineRejectionDialog.message = message
395403
engineRejectionDialog.open()
396404
}

‎tests/test_dataset_resolution.py‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,28 @@ def test_each_dataset_is_drawn_with_its_own_resolution(self, qcore_application,
5757
expected = project.model_data_for_experiment_at_index(index, q_range=q).y
5858
np.testing.assert_allclose(drawn, expected, rtol=1e-12)
5959

60+
def test_a_narrowed_q_range_keeps_the_widths_of_overlapping_measurements(self, qcore_application, tmp_path):
61+
# Two measurements of one contrast on the same q grid with very different
62+
# widths: the merged dataset holds every q twice. Restricting the q range
63+
# must not change the curve at the points that remain.
64+
q = np.array([0.01, 0.03, 0.05, 0.08, 0.10, 0.15, 0.20, 0.25, 0.30])
65+
project = Project()
66+
project.default_model()
67+
project.load_new_experiment(_write_file(tmp_path / 'sharp.txt', q, 0.002))
68+
project.append_to_experiment_at_index(0, _write_file(tmp_path / 'broad.txt', q, 0.08))
69+
merged = project.experiments[0]
70+
full = np.asarray(project.model_data_for_experiment_at_index(0).y)
71+
project.q_min = 0.03
72+
project.q_max = 0.25
73+
plotting = Plotting1d(project_lib=project, parent=None)
74+
75+
points = plotting._get_aligned_analysis_values(0)
76+
77+
x = np.asarray(merged.x)
78+
mask = (x >= 0.03) & (x <= 0.25)
79+
np.testing.assert_allclose([point['q'] for point in points], x[mask])
80+
np.testing.assert_allclose([point['calculated'] for point in points], full[mask], rtol=1e-12)
81+
6082
def test_the_second_dataset_is_not_drawn_with_the_models_resolution(self, qcore_application, tmp_path):
6183
project = two_datasets_on_one_model(tmp_path)
6284
plotting = Plotting1d(project_lib=project, parent=None)
@@ -221,6 +243,84 @@ def test_a_failing_file_is_reported_and_the_others_still_load(self, qcore_applic
221243
assert len(failures) == 1 and failures[0].startswith('missing.txt:')
222244
assert project.experiments[0].x.size == 2 * q.size
223245

246+
def test_files_from_the_qml_dialog_are_added(self, qcore_application, tmp_path):
247+
# A QML FileDialog's selectedFiles reaches the slot as a list of QUrl;
248+
# names with spaces, commas and non-ASCII characters must survive.
249+
from PySide6.QtCore import QUrl
250+
251+
project = Project()
252+
project.default_model()
253+
q = np.linspace(0.01, 0.2, 10)
254+
project.load_new_experiment(_write_file(tmp_path / 'first.txt', q, 0.01))
255+
backend = self._backend(project)
256+
failures = []
257+
backend.loadFailed.connect(failures.append)
258+
names = ['angle 2, run 7.txt', 'Messung ß µ.txt']
259+
urls = [
260+
QUrl.fromLocalFile(str(_write_file(tmp_path / name, q + 0.001 * (i + 1), 0.02)))
261+
for i, name in enumerate(names)
262+
]
263+
264+
backend.appendToCurrent(urls)
265+
266+
assert failures == []
267+
assert project.experiments[0].x.size == 3 * q.size
268+
269+
def test_a_file_url_string_is_added(self, qcore_application, tmp_path):
270+
from PySide6.QtCore import QUrl
271+
272+
project = Project()
273+
project.default_model()
274+
q = np.linspace(0.01, 0.2, 10)
275+
project.load_new_experiment(_write_file(tmp_path / 'first.txt', q, 0.01))
276+
backend = self._backend(project)
277+
failures = []
278+
backend.loadFailed.connect(failures.append)
279+
url = QUrl.fromLocalFile(str(_write_file(tmp_path / 'with space.txt', q + 0.001, 0.02))).toString()
280+
281+
backend.appendToCurrent([url])
282+
283+
assert failures == []
284+
assert project.experiments[0].x.size == 2 * q.size
285+
286+
def test_refusals_and_warnings_of_one_call_are_reported_together(self, qcore_application, tmp_path):
287+
# The GUI shows one dialog: per-file emissions would leave only the last visible.
288+
project = Project()
289+
project.default_model()
290+
q = np.linspace(0.01, 0.2, 10)
291+
project.load_new_experiment(_write_file(tmp_path / 'first.txt', q, 0.01))
292+
backend = self._backend(project)
293+
failures, cautions = [], []
294+
backend.loadFailed.connect(failures.append)
295+
backend.loadWarning.connect(cautions.append)
296+
297+
backend.appendToCurrent(
298+
[
299+
str(tmp_path / 'missing_a.txt'),
300+
str(self._three_column(tmp_path / 'plain.txt', q + 0.005)),
301+
str(tmp_path / 'missing_b.txt'),
302+
]
303+
)
304+
305+
assert cautions == []
306+
assert len(failures) == 1
307+
assert 'missing_a.txt:' in failures[0] and 'missing_b.txt:' in failures[0]
308+
assert 'plain.txt:' in failures[0]
309+
310+
def test_the_warning_is_worded_for_the_user(self, qcore_application, tmp_path):
311+
project = Project()
312+
project.default_model()
313+
q = np.linspace(0.01, 0.2, 10)
314+
project.load_new_experiment(_write_file(tmp_path / 'measured.txt', q, 0.01))
315+
backend = self._backend(project)
316+
cautions = []
317+
backend.loadWarning.connect(cautions.append)
318+
319+
backend.appendToCurrent([str(self._three_column(tmp_path / 'plain.txt', q + 0.005))])
320+
321+
assert len(cautions) == 1
322+
assert 'PercentageFwhm' not in cautions[0] and cautions[0].count('plain.txt') == 1
323+
224324
def test_appending_to_a_polarized_experiment_is_refused(self, qcore_application, tmp_path):
225325
from easyreflectometry.data import DataSet1D
226326
from easyreflectometry.data import PolarizedDataSet

0 commit comments

Comments
 (0)