From 8e44eaaa2c6cd202467ff48303694c998cb7f2ad Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Sun, 30 Aug 2026 17:13:43 +0200 Subject: [PATCH 1/3] initial version --- CHANGELOG.md | 8 + EasyReflectometryApp/Backends/Mock/Sample.qml | 18 ++ .../Backends/Py/logic/structure.py | 119 +++++++++++ .../Backends/Py/py_backend.py | 15 ++ EasyReflectometryApp/Backends/Py/sample.py | 28 +++ .../Gui/Globals/BackendWrapper.qml | 5 + .../Gui/Pages/Sample/Layout.qml | 7 +- .../Sample/MainContent/StructureView.qml | 193 ++++++++++++++++++ docs/src/tutorial.md | 12 ++ tests/factories.py | 20 ++ tests/test_logic_structure.py | 178 ++++++++++++++++ tests/test_py_backend.py | 29 +++ tests/test_py_sample.py | 37 +++- tests/test_qml_structure_view.py | 39 ++++ 14 files changed, 706 insertions(+), 2 deletions(-) create mode 100644 EasyReflectometryApp/Backends/Py/logic/structure.py create mode 100644 EasyReflectometryApp/Gui/Pages/Sample/MainContent/StructureView.qml create mode 100644 tests/test_logic_structure.py create mode 100644 tests/test_qml_structure_view.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f0d4ac4..e194ea98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# Unreleased + +- Added a **Structure** tab on the Model page: a schematic view of the layer stack with one + colored box per layer (colors per material, heights following thickness, "× N" badges for + collapsed repeating multilayers, legend and total-thickness caption). Boxes show tooltips + with material/SLD/thickness/roughness, clicking selects the layer in the sidebar editor, + and the view updates live on edits and after fits ([#242](https://github.com/easyscience/reflectometry-lib/issues/242)). + # Version 1.4.0 (3 Aug 2026) - Added Bayesian analysis: run MCMC sampling alongside classical fitting, with posterior median and credibility intervals on the main chart, trace/corner-style plots per parameter, a dedicated status display with cancellation support, and plot export. diff --git a/EasyReflectometryApp/Backends/Mock/Sample.qml b/EasyReflectometryApp/Backends/Mock/Sample.qml index 3314b0b2..1a884ac5 100644 --- a/EasyReflectometryApp/Backends/Mock/Sample.qml +++ b/EasyReflectometryApp/Backends/Mock/Sample.qml @@ -224,6 +224,24 @@ QtObject { }, ] + // Structure view: same contract and value types as Backends/Py/logic/structure.py — + // thickness/indices/repetitions are numbers, everything else strings + readonly property var structure: [ + { 'label': 'Air', 'material': 'Air', 'color': '#0173B2', 'color_end': '', 'sld': '0.00', 'isld': '0.00', 'thickness': 0.0, 'roughness': '0.0', 'assembly': 'Superphase', 'assembly_index': 0, 'layer_index': 0, 'kind': 'superphase', 'repetitions': 1 }, + { 'label': 'TypeA', 'material': 'TypeA', 'color': '#DE8F05', 'color_end': '', 'sld': '1.00', 'isld': '0.01', 'thickness': 2.5, 'roughness': '1.0', 'assembly': 'Multi-layer', 'assembly_index': 1, 'layer_index': 0, 'kind': 'layer', 'repetitions': 1 }, + { 'label': 'TypeB', 'material': 'TypeB', 'color': '#029E73', 'color_end': '', 'sld': '2.07', 'isld': '0.00', 'thickness': 5.0, 'roughness': '1.0', 'assembly': 'Multi-layer', 'assembly_index': 1, 'layer_index': 1, 'kind': 'layer', 'repetitions': 1 }, + { 'label': 'TypeA', 'material': 'TypeA', 'color': '#DE8F05', 'color_end': '', 'sld': '1.00', 'isld': '0.01', 'thickness': 2.5, 'roughness': '1.0', 'assembly': 'Multi-layer', 'assembly_index': 1, 'layer_index': 0, 'kind': 'layer', 'repetitions': 1 }, + { 'label': 'TypeB', 'material': 'TypeB', 'color': '#029E73', 'color_end': '', 'sld': '2.07', 'isld': '0.00', 'thickness': 5.0, 'roughness': '1.0', 'assembly': 'Multi-layer', 'assembly_index': 1, 'layer_index': 1, 'kind': 'layer', 'repetitions': 1 }, + { 'label': 'Substrate', 'material': 'Si', 'color': '#D55E00', 'color_end': '', 'sld': '2.07', 'isld': '0.00', 'thickness': 0.0, 'roughness': '1.0', 'assembly': 'Substrate', 'assembly_index': 2, 'layer_index': 0, 'kind': 'subphase', 'repetitions': 1 }, + ] + readonly property var structureLegend: [ + { 'label': 'Air', 'color': '#0173B2' }, + { 'label': 'TypeA', 'color': '#DE8F05' }, + { 'label': 'TypeB', 'color': '#029E73' }, + { 'label': 'Si', 'color': '#D55E00' }, + ] + readonly property real structureTotalThickness: 15.0 + // Setters function setCurrentLayerIndex(value){ console.debug(`setCurrentLayerIndex ${value}`) diff --git a/EasyReflectometryApp/Backends/Py/logic/structure.py b/EasyReflectometryApp/Backends/Py/logic/structure.py new file mode 100644 index 00000000..9f60ddd1 --- /dev/null +++ b/EasyReflectometryApp/Backends/Py/logic/structure.py @@ -0,0 +1,119 @@ +from easyreflectometry import Project as ProjectLib +from easyreflectometry.model.model import COLORS + +# An assembly whose expanded box count would exceed this collapses to its repeat unit +MAX_EXPANDED_BOXES_PER_ASSEMBLY = 12 + + +def flatten(project_lib: ProjectLib) -> tuple[list[dict], list[dict], float]: + """Flatten the current model's sample into drawable boxes for the Structure view. + + Returns (structure, legend, total_thickness): + - structure: one dict per drawn box, top to bottom (contract in MD/VISUAL_LAYERS_PLAN.md §3.2) + - legend: distinct {label, color} pairs in stack order + - total_thickness: physical total in Angstrom (collapsed repeats counted n times, caps excluded) + """ + model_index = project_lib.current_model_index + if model_index is None or not 0 <= model_index < len(project_lib._models): + return [], [], 0.0 + sample = project_lib._models[model_index].sample + + colors = _ColorMap(project_lib._materials) + boxes = [] + total_thickness = 0.0 + + for assembly_index, assembly in enumerate(sample): + if assembly.type == 'Gradient-layer': + boxes.append(_gradient_box(assembly, assembly_index, colors)) + total_thickness += boxes[-1]['thickness'] + continue + + repetitions = 1 + if assembly.type == 'Repeating Multi-layer': + repetitions = int(assembly.repetitions.value) + collapsed = repetitions * len(assembly.layers) > MAX_EXPANDED_BOXES_PER_ASSEMBLY + total_thickness += repetitions * sum(layer.thickness.value for layer in assembly.layers) + + for _ in range(1 if collapsed else repetitions): + for layer_index, layer in enumerate(assembly.layers): + boxes.append(_layer_box(layer, assembly, assembly_index, layer_index, colors)) + if collapsed: + boxes[-len(assembly.layers)]['repetitions'] = repetitions + + # The first/last drawn layers are the semi-infinite superphase/subphase caps + if boxes: + boxes[0]['kind'] = 'superphase' + boxes[-1]['kind'] = 'subphase' + total_thickness -= boxes[0]['thickness'] + (boxes[-1]['thickness'] if len(boxes) > 1 else 0.0) + + legend = [] + seen = set() + for box in boxes: + if box['material'] not in seen: + seen.add(box['material']) + legend.append({'label': box['material'], 'color': box['color']}) + + return boxes, legend, total_thickness + + +def _value(quantity) -> float: + # Material.sld is a Parameter, but MaterialSolvated.sld is a computed plain float + return float(getattr(quantity, 'value', quantity)) + + +def _layer_box(layer, assembly, assembly_index: int, layer_index: int, colors: '_ColorMap') -> dict: + material = layer.material + return { + 'label': layer.name, + 'material': material.name, + 'color': colors.get(material), + 'color_end': '', + 'sld': f'{_value(material.sld):.2f}', + 'isld': f'{_value(material.isld):.2f}', + 'thickness': float(layer.thickness.value), + 'roughness': f'{layer.roughness.value:.1f}', + 'assembly': assembly.name, + 'assembly_index': assembly_index, + 'layer_index': layer_index, + 'kind': 'layer', + 'repetitions': 1, + } + + +def _gradient_box(assembly, assembly_index: int, colors: '_ColorMap') -> dict: + # A gradient assembly is drawn as one box colored front->back; its internal + # discretization slices use anonymous materials and are never drawn. + front = assembly.front_material + back = assembly.back_material + return { + 'label': assembly.name, + 'material': f'{front.name} → {back.name}', + 'color': colors.get(front), + 'color_end': colors.get(back), + 'sld': f'{_value(front.sld):.2f}', + 'isld': f'{_value(front.isld):.2f}', + 'thickness': float(assembly.thickness), + 'roughness': f'{assembly.front_layer.roughness.value:.1f}', + 'assembly': assembly.name, + 'assembly_index': assembly_index, + 'layer_index': 0, + 'kind': 'gradient', + 'repetitions': 1, + } + + +class _ColorMap: + """Material name -> palette color; project materials by table position, + unknown (ad-hoc) materials by first-seen order. Solvated materials are + keyed on their inner dry material so the solvent does not change the color.""" + + def __init__(self, materials): + self._by_name = {material.name: COLORS[i % len(COLORS)] for i, material in enumerate(materials)} + self._next_index = len(materials) + + def get(self, material) -> str: + name = getattr(material, 'material', material).name + if name not in self._by_name: + self._by_name[name] = COLORS[self._next_index % len(COLORS)] + self._next_index += 1 + return self._by_name[name] diff --git a/EasyReflectometryApp/Backends/Py/py_backend.py b/EasyReflectometryApp/Backends/Py/py_backend.py index b59235f6..f456e108 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -167,6 +167,18 @@ def _connect_sample_page(self) -> None: self._sample.modelsTableChanged.connect(self._analysis.experimentsChanged) # Connect sample changes to multi-experiment selection signal self._sample.modelsTableChanged.connect(self.multiExperimentSelectionChanged) + # Structure view: refresh on any change to the stack. Signals overlap on some + # paths (harmless); assembliesTableChanged is the only signal a repetitions + # edit emits, modelsTableChanged the only one removeModel emits — keep both. + for signal in ( + self._sample.layersChange, + self._sample.assembliesTableChanged, + self._sample.materialsTableChanged, + self._sample.modelsIndexChanged, + self._sample.modelsTableChanged, + self._sample.externalSampleChanged, + ): + signal.connect(self._sample._clearStructureCacheAndEmit) def _connect_experiment_page(self) -> None: self._experiment.externalExperimentChanged.connect(self._relay_experiment_page_experiment_changed) @@ -183,6 +195,9 @@ def _connect_analysis_page(self) -> None: # A finished fit updates the goodness-of-fit; refresh the Summary tab's # HTML binding so it stops showing the stale pre-fit value. self._analysis.externalFittingChanged.connect(self._summary.summaryChanged) + # The fit path never emits a Sample signal; refresh the Structure view post-fit + self._analysis.externalParametersChanged.connect(self._sample._clearStructureCacheAndEmit) + self._analysis.externalFittingChanged.connect(self._sample._clearStructureCacheAndEmit) self._analysis.externalExperimentChanged.connect(self._relay_experiment_page_experiment_changed) self._analysis.externalExperimentChanged.connect(self._refresh_plots) # Update status bar when parameters change (e.g. fit checkbox toggle, post-fit) diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 7c4dcd93..5ab8ca85 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -21,6 +21,7 @@ from .logic.models import Models as ModelsLogic from .logic.parameters import Parameters as ParametersLogic from .logic.project import Project as ProjectLogic +from .logic.structure import flatten as flatten_structure logger = logging.getLogger(__name__) @@ -67,6 +68,8 @@ class Sample(QObject): layersChange = Signal() layersIndexChanged = Signal() + structureChanged = Signal() + qRangeChanged = Signal() constraintsChanged = Signal() @@ -84,6 +87,7 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._parameters_logic = ParametersLogic(project_lib) self._chached_layers = None + self._cached_structure = None self._constraint_states: Dict[str, dict[str, Any]] = {} self.connect_logic() @@ -582,6 +586,30 @@ def _clearCacheAndEmitLayersChanged(self): self._chached_layers = None self.layersChange.emit() + # # # + # Structure view (flattened whole-stack representation) + # # # + @Property('QVariantList', notify=structureChanged) + def structure(self) -> list[dict]: + return self._structure_parts()[0] + + @Property('QVariantList', notify=structureChanged) + def structureLegend(self) -> list[dict]: + return self._structure_parts()[1] + + @Property(float, notify=structureChanged) + def structureTotalThickness(self) -> float: + return self._structure_parts()[2] + + def _structure_parts(self) -> tuple[list[dict], list[dict], float]: + if self._cached_structure is None: + self._cached_structure = flatten_structure(self._project_lib) + return self._cached_structure + + def _clearStructureCacheAndEmit(self): + self._cached_structure = None + self.structureChanged.emit() + # # # # Constraints # # # diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index d8fc7fba..b1dd90f9 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -164,6 +164,11 @@ QtObject { function sampleSetCurrentAssemblyConformalRoughness(value) { activeBackend.sample.setCurrentAssemblyConformalRoughness(value) } function sampleSetCurrentAssemblyRepeatedLayerReptitions(value) { activeBackend.sample.setCurrentAssemblyRepeatedLayerReptitions(value) } + // Structure view (flattened whole-stack representation) + readonly property var sampleStructure: activeBackend.sample.structure + readonly property var sampleStructureLegend: activeBackend.sample.structureLegend + readonly property real sampleStructureTotalThickness: activeBackend.sample.structureTotalThickness + // Layer readonly property var sampleLayers: activeBackend.sample.layers readonly property string sampleCurrentLayerName: activeBackend.sample.currentLayerName diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Layout.qml b/EasyReflectometryApp/Gui/Pages/Sample/Layout.qml index ff9864a7..01c0f16b 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Layout.qml @@ -12,13 +12,18 @@ import Gui.Globals as Globals EaComponents.ContentPage { mainView: EaComponents.MainContent { tabs: [ - EaElements.TabButton { text: qsTr('Reflectivity') } + EaElements.TabButton { text: qsTr('Reflectivity') }, + EaElements.TabButton { text: qsTr('Structure') } ] items: [ Loader { source: `MainContent/CombinedView.qml` onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`) + }, + Loader { + source: `MainContent/StructureView.qml` + onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`) } ] } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/StructureView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/StructureView.qml new file mode 100644 index 00000000..91e2914f --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/StructureView.qml @@ -0,0 +1,193 @@ +import QtQuick +import QtQuick.Controls + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements + +import Gui.Globals as Globals + + +// Schematic of the current model's layer stack: one colored box per layer, +// heights proportional to thickness (clamped), semi-infinite caps fixed. +// Data contract: MD/VISUAL_LAYERS_PLAN.md §3.2 +Rectangle { + id: root + + readonly property var boxes: Globals.BackendWrapper.sampleStructure + + readonly property real capPx: 28 + readonly property real minBoxPx: 22 + readonly property real maxBoxPx: 120 + readonly property real stackWidth: Math.min(600, Math.max(Math.min(300, width - 4 * EaStyle.Sizes.fontPixelSize), 0.4 * width)) + // Sum of proportional (non-cap) thicknesses + readonly property real totalT: boxes.reduce((sum, box) => sum + (isCap(box) ? 0 : box.thickness), 0) + + color: EaStyle.Colors.chartBackground + + function isCap(box) { + return box.kind === 'superphase' || box.kind === 'subphase' + } + + function pixelHeight(box) { + if (isCap(box)) + return capPx + if (totalT <= 0) + return minBoxPx + const available = flickable.height - 2 * capPx + return Math.min(maxBoxPx, Math.max(minBoxPx, 0.8 * available * box.thickness / totalT)) + } + + function fillColor(c) { + return Qt.alpha(Qt.color(String(c)), 0.35) + } + + // Empty state + EaElements.Label { + anchors.centerIn: parent + visible: root.boxes.length === 0 + text: qsTr('No layers defined') + color: EaStyle.Colors.themeForegroundMinor + } + + // Model name header (only useful when several models exist) + EaElements.Label { + id: header + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: EaStyle.Sizes.fontPixelSize * 0.5 + visible: Globals.BackendWrapper.sampleModels.length > 1 && root.boxes.length > 0 + text: Globals.BackendWrapper.sampleModels[Globals.BackendWrapper.sampleCurrentModelIndex]?.label ?? '' + color: EaStyle.Colors.themeForegroundMinor + } + + Flickable { + id: flickable + anchors.top: parent.top + anchors.bottom: footer.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: EaStyle.Sizes.fontPixelSize * 2 + contentHeight: stack.height + clip: true + ScrollIndicator.vertical: ScrollIndicator {} + + Column { + id: stack + width: root.stackWidth + x: (flickable.width - width) / 2 + + Repeater { + model: root.boxes + + Rectangle { + readonly property bool selected: modelData.assembly_index === Globals.BackendWrapper.sampleCurrentAssemblyIndex + && modelData.layer_index === Globals.BackendWrapper.sampleCurrentLayerIndex + + width: stack.width + height: root.pixelHeight(modelData) + color: root.fillColor(modelData.color) + border.color: modelData.color + border.width: selected ? 3 : 1 + gradient: modelData.kind === 'gradient' ? boxGradient : null + + Gradient { + id: boxGradient + GradientStop { position: 0.0; color: root.fillColor(modelData.color) } + GradientStop { position: 1.0; color: root.fillColor(modelData.color_end || modelData.color) } + } + + EaElements.Label { + anchors.centerIn: parent + visible: parent.height >= root.minBoxPx + text: `${index} ${modelData.label}` + elide: Text.ElideRight + width: Math.min(implicitWidth, parent.width - EaStyle.Sizes.fontPixelSize) + } + + // Thickness annotation + EaElements.Label { + anchors.right: parent.right + anchors.rightMargin: EaStyle.Sizes.fontPixelSize * 0.5 + anchors.verticalCenter: parent.verticalCenter + visible: !root.isCap(modelData) && parent.height >= root.minBoxPx && parent.width > 250 + text: `${modelData.thickness.toFixed(1)} Å` + color: EaStyle.Colors.themeForegroundMinor + } + + // Repetition badge for a collapsed repeat unit + EaElements.Label { + anchors.left: parent.right + anchors.leftMargin: EaStyle.Sizes.fontPixelSize * 0.5 + anchors.verticalCenter: parent.verticalCenter + visible: modelData.repetitions > 1 + text: `× ${modelData.repetitions}` + } + + MouseArea { + id: boxMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + Globals.BackendWrapper.sampleSetCurrentAssemblyIndex(modelData.assembly_index) + Globals.BackendWrapper.sampleSetCurrentLayerIndex(modelData.layer_index) + } + } + + ToolTip.visible: boxMouse.containsMouse + ToolTip.text: { + let lines = [ + `${modelData.label} (${modelData.material})`, + qsTr('SLD: %1 + %2i').arg(modelData.sld).arg(modelData.isld), + qsTr('Thickness: %1 Å').arg(modelData.thickness.toFixed(1)), + qsTr('Roughness: %1 Å').arg(modelData.roughness), + qsTr('Assembly: %1').arg(modelData.assembly) + ] + if (modelData.repetitions > 1) + lines.push(qsTr('Repeated × %1').arg(modelData.repetitions)) + return lines.join('\n') + } + } + } + } + } + + // Legend + total thickness + Column { + id: footer + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottomMargin: EaStyle.Sizes.fontPixelSize + spacing: EaStyle.Sizes.fontPixelSize * 0.25 + visible: root.boxes.length > 0 + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: EaStyle.Sizes.fontPixelSize + + Repeater { + model: Globals.BackendWrapper.sampleStructureLegend + + Row { + spacing: EaStyle.Sizes.fontPixelSize * 0.25 + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: EaStyle.Sizes.fontPixelSize + height: EaStyle.Sizes.fontPixelSize + color: root.fillColor(modelData.color) + border.color: modelData.color + } + EaElements.Label { + text: modelData.label + } + } + } + } + + EaElements.Label { + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr('Total thickness: %1 Å').arg(Globals.BackendWrapper.sampleStructureTotalThickness.toFixed(1)) + color: EaStyle.Colors.themeForegroundMinor + } + } +} diff --git a/docs/src/tutorial.md b/docs/src/tutorial.md index 7e2b1682..a80b31b3 100644 --- a/docs/src/tutorial.md +++ b/docs/src/tutorial.md @@ -35,3 +35,15 @@ When a project is started two further buttons is visualised, and settings is the - **A**: Save project. - **B**: Reset to start. - **C**: Setting, is further explained in settings. + +## Sample structure view +The **Model** page has a **Structure** tab next to **Reflectivity** showing a schematic of +the current model's layer stack: one colored box per layer, ambient medium on top, substrate +at the bottom. Boxes share a color per material (see the legend), box heights follow layer +thickness, and repeated multilayers with many repetitions are drawn once with a "× N" badge. + +Hover a box for its material, SLD, thickness and roughness; click it to select that layer in +the sidebar editor. The view updates immediately when the model changes, including after a fit. + +Note the view is a schematic, not a to-scale cross-section: heights are clamped so very thin +layers stay visible and very thick ones do not crowd out the rest. diff --git a/tests/factories.py b/tests/factories.py index d13be5e4..7420a6b0 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -119,6 +119,26 @@ def __init__(self, repetitions=1, name='Repeating Multi-layer', layers=None): self.repetitions = ValueHolder(repetitions) +class FakeSolvatedMaterial: + """Mirrors MaterialSolvated: display name ' in ', inner dry + `.material`, and sld/isld as computed plain floats (not ValueHolders).""" + + def __init__(self, material, solvent_name='D2O', sld=1.0, isld=0.0): + self.material = material + self.name = f'{material.name} in {solvent_name}' + self.sld = sld + self.isld = isld + + +class FakeGradientLayer(FakeAssembly): + def __init__(self, name='Gradient', front_material=None, back_material=None, thickness=2.0): + super().__init__(name=name, assembly_type='Gradient-layer') + self.front_material = front_material or FakeMaterial('Air') + self.back_material = back_material or FakeMaterial('D2O') + self.thickness = thickness # plain float, like the real GradientLayer + self.front_layer = self.layers[0] + + class FakeSurfactantLayer(FakeAssembly): def __init__(self, name='Surfactant Layer', layers=None): super().__init__(name=name, assembly_type='Surfactant Layer', layers=layers or [FakeLayer(), FakeLayer()]) diff --git a/tests/test_logic_structure.py b/tests/test_logic_structure.py new file mode 100644 index 00000000..9cfdae60 --- /dev/null +++ b/tests/test_logic_structure.py @@ -0,0 +1,178 @@ +from EasyReflectometryApp.Backends.Py.logic.structure import COLORS +from EasyReflectometryApp.Backends.Py.logic.structure import flatten +from tests.factories import FakeGradientLayer +from tests.factories import FakeRepeatingMultilayer +from tests.factories import FakeSolvatedMaterial +from tests.factories import make_assembly +from tests.factories import make_layer +from tests.factories import make_material +from tests.factories import make_material_collection +from tests.factories import make_model +from tests.factories import make_model_collection +from tests.factories import make_project +from tests.factories import make_sample + + +def _project(sample, materials=None): + materials = materials or make_material_collection(make_material('Air'), make_material('SiO2'), make_material('Si')) + return make_project(materials=materials, models=make_model_collection(make_model(sample=sample))) + + +def _default_like_sample(materials): + return make_sample( + make_assembly(name='Superphase', layers=[make_layer(name='Air Layer', material=materials[0], thickness=0.0)]), + make_assembly(name='SiO2', layers=[make_layer(name='SiO2 Layer', material=materials[1], thickness=25.0)]), + make_assembly(name='Substrate', layers=[make_layer(name='Si Layer', material=materials[2], thickness=0.0)]), + ) + + +def test_default_model_boxes_are_tagged_and_not_double_counted(): + materials = make_material_collection(make_material('Air'), make_material('SiO2', 3.47), make_material('Si', 2.07)) + project = _project(_default_like_sample(materials), materials) + + boxes, legend, total = flatten(project) + + assert [box['label'] for box in boxes] == ['Air Layer', 'SiO2 Layer', 'Si Layer'] + assert [box['kind'] for box in boxes] == ['superphase', 'layer', 'subphase'] + assert [box['assembly_index'] for box in boxes] == [0, 1, 2] + assert [box['layer_index'] for box in boxes] == [0, 0, 0] + assert [box['repetitions'] for box in boxes] == [1, 1, 1] + assert [box['color'] for box in boxes] == [COLORS[0], COLORS[1], COLORS[2]] + assert boxes[1]['sld'] == '3.47' + assert isinstance(boxes[1]['thickness'], float) + assert total == 25.0 # caps excluded + + +def test_single_assembly_tags_first_and_last_layer_only(): + materials = make_material_collection(make_material('Air'), make_material('Si')) + sample = make_sample( + make_assembly( + name='Only', + layers=[ + make_layer(name='Top', material=materials[0]), + make_layer(name='Mid', material=materials[1], thickness=50.0), + make_layer(name='Bottom', material=materials[1]), + ], + ) + ) + + boxes, _, total = flatten(_project(sample, materials)) + + assert [box['kind'] for box in boxes] == ['superphase', 'layer', 'subphase'] + assert total == 50.0 + + +def test_small_repeating_multilayer_expands(): + materials = make_material_collection(make_material('Air'), make_material('A'), make_material('B')) + repeating = FakeRepeatingMultilayer( + repetitions=2, + name='Rep', + layers=[make_layer(name='LA', material=materials[1], thickness=2.5), make_layer(name='LB', material=materials[2], thickness=5.0)], + ) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0], thickness=0.0)]), + repeating, + make_assembly(name='Bottom', layers=[make_layer(material=materials[0], thickness=0.0)]), + ) + + boxes, _, total = flatten(_project(sample, materials)) + + assert [box['label'] for box in boxes[1:-1]] == ['LA', 'LB', 'LA', 'LB'] + assert all(box['repetitions'] == 1 for box in boxes) + assert total == 2 * 7.5 + + +def test_large_repeating_multilayer_collapses_with_badge_on_first_box(): + materials = make_material_collection(make_material('Air'), make_material('A'), make_material('B')) + repeating = FakeRepeatingMultilayer( + repetitions=100, + name='Rep', + layers=[make_layer(name='LA', material=materials[1], thickness=2.5), make_layer(name='LB', material=materials[2], thickness=5.0)], + ) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0], thickness=0.0)]), + repeating, + make_assembly(name='Bottom', layers=[make_layer(material=materials[0], thickness=0.0)]), + ) + + boxes, _, total = flatten(_project(sample, materials)) + + assert [box['label'] for box in boxes[1:-1]] == ['LA', 'LB'] # unit emitted once + assert [box['repetitions'] for box in boxes[1:-1]] == [100, 1] # badge on first box only + assert total == 100 * 7.5 # physical total counts all repeats + + +def test_gradient_layer_collapses_to_one_box(): + materials = make_material_collection(make_material('Air'), make_material('D2O')) + gradient = FakeGradientLayer(name='Grad', front_material=materials[0], back_material=materials[1], thickness=2.0) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0], thickness=0.0)]), + gradient, + make_assembly(name='Bottom', layers=[make_layer(material=materials[1], thickness=0.0)]), + ) + + boxes, _, total = flatten(_project(sample, materials)) + + grad_box = boxes[1] + assert grad_box['kind'] == 'gradient' + assert grad_box['label'] == 'Grad' + assert grad_box['thickness'] == 2.0 + assert grad_box['color'] == COLORS[0] + assert grad_box['color_end'] == COLORS[1] + assert total == 2.0 + + +def test_ad_hoc_material_gets_fallback_color_without_raising(): + materials = make_material_collection(make_material('Air')) + stray = make_material('NotInTable') + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0])]), + make_assembly(name='Mid', layers=[make_layer(material=stray)]), + make_assembly(name='Bottom', layers=[make_layer(material=materials[0])]), + ) + + boxes, _, _ = flatten(_project(sample, materials)) + + assert boxes[1]['color'] == COLORS[1] # first fallback slot after the 1-entry table + + +def test_solvated_material_is_colored_by_inner_dry_material(): + materials = make_material_collection(make_material('Air'), make_material('C32D64')) + solvated = FakeSolvatedMaterial(materials[1], solvent_name='Air', sld=8.29) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0])]), + make_assembly(name='Tail', layers=[make_layer(name='DPPC Tail', material=solvated)]), + make_assembly(name='Bottom', layers=[make_layer(material=materials[0])]), + ) + + boxes, _, _ = flatten(_project(sample, materials)) + + assert boxes[1]['material'] == 'C32D64 in Air' # display name stays solvated + assert boxes[1]['color'] == COLORS[1] # color keyed on inner dry material + assert boxes[1]['sld'] == '8.29' # plain-float sld unwrapped + + +def test_empty_models_and_stale_index_return_empty(): + project = make_project() + assert flatten(project) == ([], [], 0.0) + + project = _project(_default_like_sample(make_material_collection(make_material('Air'), make_material('SiO2'), make_material('Si')))) + project.current_model_index = 5 + assert flatten(project) == ([], [], 0.0) + + +def test_legend_lists_only_used_materials_once(): + materials = make_material_collection(make_material('Air'), make_material('Si'), make_material('Unused')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0])]), + make_assembly(name='Mid', layers=[make_layer(material=materials[1])]), + make_assembly(name='Mid2', layers=[make_layer(material=materials[1])]), + make_assembly(name='Bottom', layers=[make_layer(material=materials[0])]), + ) + + _, legend, _ = flatten(_project(sample, materials)) + + assert legend == [ + {'label': 'Air', 'color': COLORS[0]}, + {'label': 'Si', 'color': COLORS[1]}, + ] diff --git a/tests/test_py_backend.py b/tests/test_py_backend.py index ffe5fb89..cdfcd527 100644 --- a/tests/test_py_backend.py +++ b/tests/test_py_backend.py @@ -34,15 +34,22 @@ class StubSample(QObject): modelsIndexChanged = Signal() assembliesTableChanged = Signal() assembliesIndexChanged = Signal() + layersChange = Signal() + structureChanged = Signal() qRangeChanged = Signal() def __init__(self, _project_lib): super().__init__() self.clear_calls = 0 + self.structure_clear_calls = 0 def _clearCacheAndEmitLayersChanged(self): self.clear_calls += 1 + def _clearStructureCacheAndEmit(self): + self.structure_clear_calls += 1 + self.structureChanged.emit() + class StubExperiment(QObject): externalExperimentChanged = Signal() @@ -335,3 +342,25 @@ def test_passes_experiment_index(self): plotting.getAnalysisDataPoints.assert_called_once_with(5) + + +def test_backend_wires_structure_refresh_to_sample_and_analysis_signals(monkeypatch, qcore_application): + # The Structure view must refresh on every stack-changing signal, including + # modelsTableChanged (the only signal removeModel emits), assembliesTableChanged + # (the only signal a repetitions edit emits), and the post-fit Analysis signals + # (the fit path never emits a Sample signal). + backend = _make_backend(monkeypatch) + + for signal in ( + backend._sample.layersChange, + backend._sample.assembliesTableChanged, + backend._sample.materialsTableChanged, + backend._sample.modelsIndexChanged, + backend._sample.modelsTableChanged, + backend._sample.externalSampleChanged, + backend._analysis.externalParametersChanged, + backend._analysis.externalFittingChanged, + ): + signal.emit() + + assert backend._sample.structure_clear_calls == 8 diff --git a/tests/test_py_sample.py b/tests/test_py_sample.py index 3570c366..bf62dab8 100644 --- a/tests/test_py_sample.py +++ b/tests/test_py_sample.py @@ -39,4 +39,39 @@ def test_remove_selected_assembly_refreshes_cached_layers_and_clamps_layer_index assert backend.currentAssemblyIndex == 1 assert backend.currentAssemblyType == 'Multi-layer' assert backend.currentLayerIndex == 0 - assert [layer['label'] for layer in backend.layers] == ['Bottom Layer'] \ No newline at end of file + assert [layer['label'] for layer in backend.layers] == ['Bottom Layer'] + +def test_structure_properties_expose_flattened_stack(qcore_application): + materials = make_material_collection(make_material('Air'), make_material('Si')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(name='Air Layer', material=materials[0], thickness=0.0)]), + make_assembly(name='Mid', layers=[make_layer(name='Si Layer', material=materials[1], thickness=30.0)]), + make_assembly(name='Bottom', layers=[make_layer(name='Sub Layer', material=materials[1], thickness=0.0)]), + ) + backend = Sample(make_project(materials=materials, models=make_model_collection(make_model(sample=sample)))) + + assert [box['kind'] for box in backend.structure] == ['superphase', 'layer', 'subphase'] + assert [entry['label'] for entry in backend.structureLegend] == ['Air', 'Si'] + assert backend.structureTotalThickness == 30.0 + + +def test_structure_cache_cleared_and_signal_emitted_on_invalidation(qcore_application): + materials = make_material_collection(make_material('Air'), make_material('Si')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0], thickness=0.0)]), + make_assembly(name='Mid', layers=[make_layer(name='Si Layer', material=materials[1], thickness=30.0)]), + make_assembly(name='Bottom', layers=[make_layer(material=materials[1], thickness=0.0)]), + ) + backend = Sample(make_project(materials=materials, models=make_model_collection(make_model(sample=sample)))) + emitted = [] + backend.structureChanged.connect(lambda: emitted.append(True)) + + assert len(backend.structure) == 3 + sample[1].layers.append(make_layer(name='New Layer', material=materials[1], thickness=10.0)) + assert len(backend.structure) == 3 # cached until invalidated + + backend._clearStructureCacheAndEmit() + + assert emitted == [True] + assert len(backend.structure) == 4 + assert backend.structureTotalThickness == 40.0 diff --git a/tests/test_qml_structure_view.py b/tests/test_qml_structure_view.py new file mode 100644 index 00000000..25824060 --- /dev/null +++ b/tests/test_qml_structure_view.py @@ -0,0 +1,39 @@ +"""Source-level assertions on the Structure tab QML (no QML engine is instantiated; +rendering is verified by running the app).""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GUI = ROOT / 'EasyReflectometryApp' / 'Gui' + + +def test_sample_page_declares_structure_tab(): + layout_qml = (GUI / 'Pages' / 'Sample' / 'Layout.qml').read_text(encoding='utf-8') + + assert "EaElements.TabButton { text: qsTr('Structure') }" in layout_qml + assert 'MainContent/StructureView.qml' in layout_qml + + +def test_structure_view_binds_to_backend_contract(): + view_qml = (GUI / 'Pages' / 'Sample' / 'MainContent' / 'StructureView.qml').read_text(encoding='utf-8') + + assert 'Globals.BackendWrapper.sampleStructure' in view_qml + assert 'Globals.BackendWrapper.sampleStructureLegend' in view_qml + assert 'Globals.BackendWrapper.sampleStructureTotalThickness' in view_qml + # Click-to-select wiring + assert 'sampleSetCurrentAssemblyIndex(modelData.assembly_index)' in view_qml + assert 'sampleSetCurrentLayerIndex(modelData.layer_index)' in view_qml + # Empty state and overflow handling + assert "qsTr('No layers defined')" in view_qml + assert 'Flickable' in view_qml + + +def test_backend_wrapper_and_mock_expose_structure_properties(): + wrapper_qml = (GUI / 'Globals' / 'BackendWrapper.qml').read_text(encoding='utf-8') + mock_qml = (ROOT / 'EasyReflectometryApp' / 'Backends' / 'Mock' / 'Sample.qml').read_text(encoding='utf-8') + + for name in ('structure', 'structureLegend', 'structureTotalThickness'): + assert f'activeBackend.sample.{name}' in wrapper_qml + assert name in mock_qml + # Mock must keep the numeric thickness convention (not the all-string layers style) + assert "'thickness': 2.5" in mock_qml From 929eaa81cd2283434bd833f63c7ff9fa9595ed8d Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Tue, 1 Sep 2026 18:03:42 +0200 Subject: [PATCH 2/3] bugfix: single-layer sample is misclassified and reports zero total thickness --- .../Backends/Py/logic/structure.py | 11 +++++---- tests/test_logic_structure.py | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/logic/structure.py b/EasyReflectometryApp/Backends/Py/logic/structure.py index 9f60ddd1..6645011b 100644 --- a/EasyReflectometryApp/Backends/Py/logic/structure.py +++ b/EasyReflectometryApp/Backends/Py/logic/structure.py @@ -40,11 +40,14 @@ def flatten(project_lib: ProjectLib) -> tuple[list[dict], list[dict], float]: if collapsed: boxes[-len(assembly.layers)]['repetitions'] = repetitions - # The first/last drawn layers are the semi-infinite superphase/subphase caps + # The first/last drawn layers are the semi-infinite superphase/subphase caps and are + # excluded from the total. Only a plain layer is retagged: a gradient assembly at either + # end keeps its own kind (and its thickness), and a lone box is a superphase only. if boxes: - boxes[0]['kind'] = 'superphase' - boxes[-1]['kind'] = 'subphase' - total_thickness -= boxes[0]['thickness'] + (boxes[-1]['thickness'] if len(boxes) > 1 else 0.0) + for box, kind in ((boxes[0], 'superphase'), (boxes[-1], 'subphase')): + if box['kind'] == 'layer': + box['kind'] = kind + total_thickness -= box['thickness'] legend = [] seen = set() diff --git a/tests/test_logic_structure.py b/tests/test_logic_structure.py index 9cfdae60..5391e8e4 100644 --- a/tests/test_logic_structure.py +++ b/tests/test_logic_structure.py @@ -62,6 +62,16 @@ def test_single_assembly_tags_first_and_last_layer_only(): assert total == 50.0 +def test_lone_layer_is_tagged_superphase_only(): + materials = make_material_collection(make_material('Air')) + sample = make_sample(make_assembly(name='Only', layers=[make_layer(name='Air Layer', material=materials[0], thickness=10.0)])) + + boxes, _, total = flatten(_project(sample, materials)) + + assert [box['kind'] for box in boxes] == ['superphase'] # not overwritten by the subphase tag + assert total == 0.0 # the only box is a cap, so no finite film is left + + def test_small_repeating_multilayer_expands(): materials = make_material_collection(make_material('Air'), make_material('A'), make_material('B')) repeating = FakeRepeatingMultilayer( @@ -122,6 +132,20 @@ def test_gradient_layer_collapses_to_one_box(): assert total == 2.0 +def test_gradient_at_either_end_keeps_its_kind_and_thickness(): + materials = make_material_collection(make_material('Air'), make_material('D2O')) + sample = make_sample( + FakeGradientLayer(name='Top Grad', front_material=materials[0], back_material=materials[1], thickness=2.0), + make_assembly(name='Mid', layers=[make_layer(material=materials[1], thickness=25.0)]), + FakeGradientLayer(name='Bottom Grad', front_material=materials[1], back_material=materials[0], thickness=3.0), + ) + + boxes, _, total = flatten(_project(sample, materials)) + + assert [box['kind'] for box in boxes] == ['gradient', 'layer', 'gradient'] # caps never overwrite a gradient + assert total == 30.0 # no cap thickness to exclude + + def test_ad_hoc_material_gets_fallback_color_without_raising(): materials = make_material_collection(make_material('Air')) stray = make_material('NotInTable') From a0b42206feeefb8125c9cb8ad372a41c9f898a54 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Tue, 1 Sep 2026 21:31:26 +0200 Subject: [PATCH 3/3] updated docs --- .../Backends/Py/logic/structure.py | 11 +- INSTALLATION.md | 2 +- README.md | 3 +- ROADMAP.md | 13 ++- docs/src/index.md | 4 + docs/src/installation.md | 8 +- docs/src/tutorial.md | 12 -- docs/src/tutorials/bayesian.md | 94 ++++++++++++++++ docs/src/tutorials/datasets.md | 33 ++++++ docs/src/tutorials/load_data.md | 5 + docs/src/tutorials/magnetism.md | 88 +++++++++++++++ docs/src/tutorials/model_def.md | 104 ++++++++++++++++-- docs/src/tutorials/polarized_data.md | 98 +++++++++++++++++ docs/src/tutorials/simple_fitting.md | 24 ++++ 14 files changed, 469 insertions(+), 30 deletions(-) create mode 100644 docs/src/tutorials/bayesian.md create mode 100644 docs/src/tutorials/datasets.md create mode 100644 docs/src/tutorials/magnetism.md create mode 100644 docs/src/tutorials/polarized_data.md diff --git a/EasyReflectometryApp/Backends/Py/logic/structure.py b/EasyReflectometryApp/Backends/Py/logic/structure.py index 6645011b..2c0d6f64 100644 --- a/EasyReflectometryApp/Backends/Py/logic/structure.py +++ b/EasyReflectometryApp/Backends/Py/logic/structure.py @@ -9,7 +9,16 @@ def flatten(project_lib: ProjectLib) -> tuple[list[dict], list[dict], float]: """Flatten the current model's sample into drawable boxes for the Structure view. Returns (structure, legend, total_thickness): - - structure: one dict per drawn box, top to bottom (contract in MD/VISUAL_LAYERS_PLAN.md §3.2) + - structure: one dict per drawn box, top to bottom, with the keys + label layer name, or assembly name for a gradient + material material name, or 'front -> back' for a gradient + color box color; color_end is the second color of a gradient, else '' + sld, isld real/imaginary SLD of the material, preformatted to 2 decimals + thickness layer thickness in Angstrom, as a float + roughness upper roughness in Angstrom, preformatted to 1 decimal + assembly assembly name, and assembly_index/layer_index to address the layer + kind 'layer' | 'gradient' | 'superphase' | 'subphase' + repetitions n for a collapsed repeating multilayer, else 1 - legend: distinct {label, color} pairs in stack order - total_thickness: physical total in Angstrom (collapsed repeats counted n times, caps excluded) """ diff --git a/INSTALLATION.md b/INSTALLATION.md index a8bfca08..2c3854c7 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -5,7 +5,7 @@ To make the installation of EasyReflectometry as easy as possible, we prepare pa - [Windows](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.4.0/EasyReflectometryApp_v1.4.0_windows-2022.exe) - [MacOS](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.4.0/EasyReflectometryApp_v1.4.0_macos-14-AppleSilicon.zip) (ARM) - [Linux](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.4.0/EasyReflectometryApp_v1.4.0_ubuntu-22.04) (built on Ubuntu-22.04) -- [Linux](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.4.0/EasyReflectometryApp_v1.4.0_ubuntu-24.04) (built on Ubuntu-22.04) +- [Linux](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.4.0/EasyReflectometryApp_v1.4.0_ubuntu-24.04) (built on Ubuntu-24.04) If the relevant EasyReflectometry installation does not work on your system, then please try installation from source. diff --git a/README.md b/README.md index 1c03b34a..67e51885 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,14 @@ Current main features of **EasyReflectometry**: - Growing support for flexible _item_ types, including chemically consistent models. - Ability to corefine multiple contrasts of reflectometry data. - Multiple minimization engines: [lmfit](https://lmfit.github.io/lmfit-py), [bumps](https://github.com/bumps/bumps) and [DFO-LS](https://github.com/numericalalgorithmsgroup/dfols) (including the differential evolution method). +- Support for magnetic structures: a magnetic scattering length density and in-plane moment angle per layer, refined with [refl1d](https://refl1d.readthedocs.io/en/latest/). +- Support for polarised reflectometry measurements: import of one file per spin channel, spin-asymmetry views, and simultaneous refinement of all measured spin channels against a single model. - Interactive HTML and standard PDF report generation. - Undo/redo for both parameter changes and fitting. - Saving and loading of projects. Planned improvements / new functionality for **EasyReflectometry**: -- Support for magnetic structures and polarised reflectometry measurements. - Support for mixed model reflectometry datasets. - Reading of q-dependent resolution from a file. diff --git a/ROADMAP.md b/ROADMAP.md index f128fd24..88b3eb44 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,8 +28,8 @@ gantt ESS Start of User Operation :milestone, m3, 2026-11, 2min section Hard Condensed Matter - Magnetism support :c1, after m1, 40w - Spin asymmetry and polarisation analysis :c2, after m2, 30w + Magnetism support :done, c1, after m1, 40w + Spin asymmetry and polarisation analysis :done, c2, after m2, 30w section Item Library # Bilayer item :d1, 2024-01, 8w @@ -121,7 +121,10 @@ This would involve significant EasyCore work, in addition to the development of ### Magnetism support -This will begin with enabling the magnetism functionality available in `refl1d`, concurrent to which the graphical user interface will be designed with input from the relevant user communities. +**Delivered.** Magnetic layers can be defined on the Model page and refined with `refl1d`; +see the [magnetism tutorial](./docs/src/tutorials/magnetism.md). + +This began with enabling the magnetism functionality available in `refl1d`, concurrent to which the graphical user interface will be designed with input from the relevant user communities. - [Discuss with user community and document requirements for magnetism](https://github.com/easyScience/EasyReflectometryApp/issues/113) - [Expose magnetisation in EasyReflectometryLib](https://github.com/easyScience/EasyReflectometryLib/issues/21) @@ -130,6 +133,10 @@ This will begin with enabling the magnetism functionality available in `refl1d`, ### Spin asymmetry and polarisation analysis +**Delivered.** Polarised experiments are imported one file per spin channel, displayed per channel, +fitted together and shown as spin asymmetry; see the +[polarised data tutorial](./docs/src/tutorials/polarized_data.md). + Enabling the analysis of difference between different spin flipper state - [Discuss with user community and document requirements for polarisation analysis](https://github.com/easyScience/EasyReflectometryApp/issues/115) diff --git a/docs/src/index.md b/docs/src/index.md index b60a735a..1a2522b5 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -13,6 +13,10 @@ tutorial.md tutorials/model_def.md tutorials/load_data.md tutorials/simple_fitting.md +tutorials/magnetism.md +tutorials/polarized_data.md +tutorials/bayesian.md tutorials/summary.md +tutorials/datasets.md tutorials/settings.md ``` \ No newline at end of file diff --git a/docs/src/installation.md b/docs/src/installation.md index ae16701d..edb9069a 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -1,10 +1,10 @@ # Installation To make the installation of EasyReflectometry as easy as possible, we prepare packaged releases for three major operating systems: -- [Windows](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.1.1/EasyReflectometryApp_v1.1.1_windows-2022.exe) -- [MacOS (Silicon)](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.1.1/EasyReflectometryApp_v1.1.1_macos-14-AppleSilicon.zip) -- [Linux (built on Ubuntu-24.04)](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.1.1/EasyReflectometryApp_v1.1.1_ubuntu-22.04) -- [Linux (built on Ubuntu-22.04)](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.1.1/EasyReflectometryApp_v1.1.1_ubuntu-24.04) +- [Windows](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.4.0/EasyReflectometryApp_v1.4.0_windows-2022.exe) +- [MacOS (Silicon)](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.4.0/EasyReflectometryApp_v1.4.0_macos-14-AppleSilicon.zip) +- [Linux (built on Ubuntu-22.04)](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.4.0/EasyReflectometryApp_v1.4.0_ubuntu-22.04) +- [Linux (built on Ubuntu-24.04)](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.4.0/EasyReflectometryApp_v1.4.0_ubuntu-24.04) If the relevant EasyReflectometry installation does not work on your system, then please try installation from source. diff --git a/docs/src/tutorial.md b/docs/src/tutorial.md index a80b31b3..7e2b1682 100644 --- a/docs/src/tutorial.md +++ b/docs/src/tutorial.md @@ -35,15 +35,3 @@ When a project is started two further buttons is visualised, and settings is the - **A**: Save project. - **B**: Reset to start. - **C**: Setting, is further explained in settings. - -## Sample structure view -The **Model** page has a **Structure** tab next to **Reflectivity** showing a schematic of -the current model's layer stack: one colored box per layer, ambient medium on top, substrate -at the bottom. Boxes share a color per material (see the legend), box heights follow layer -thickness, and repeated multilayers with many repetitions are drawn once with a "× N" badge. - -Hover a box for its material, SLD, thickness and roughness; click it to select that layer in -the sidebar editor. The view updates immediately when the model changes, including after a fit. - -Note the view is a schematic, not a to-scale cross-section: heights are clamped so very thin -layers stay visible and very thick ones do not crowd out the rest. diff --git a/docs/src/tutorials/bayesian.md b/docs/src/tutorials/bayesian.md new file mode 100644 index 00000000..92c16fde --- /dev/null +++ b/docs/src/tutorials/bayesian.md @@ -0,0 +1,94 @@ +# Bayesian analysis + +Next to the classical minimisers the app can sample the posterior distribution of the fitted +parameters with the BUMPS DREAM sampler. Instead of a single best value per parameter you +get a distribution: a median, a credible interval, and the correlations between parameters. + +## Starting a sampling run + +Bayesian sampling is selected like a minimiser. In `Analysis` › `Advanced` › +**Minimization method**, pick **BUMPS-DREAM (Bayesian)** - the first entry of the +**Minimizer** drop-down. + + + +The settings below the drop-down change with the choice. Instead of the classical +**Tolerance** and **Max evaluations**, the sampler shows: + +| Setting | Meaning | +|---|---| +| **Samples** | Total number of samples to draw. | +| **Burn-in steps** | Initial steps discarded before the chains are recorded. | +| **Population** | Number of chains walking the parameter space. | +| **Thinning** | Keep every n-th draw, to reduce autocorrelation. | +| **Initializer** | How the starting population is spread over the parameter ranges. | + +The parameters that are sampled, and the ranges they are sampled in, are the ones ticked +for fitting in the `Basic controls`, exactly as for a classical fit. + +With the Bayesian minimiser selected, the fit button in `Basic controls` reads +**Start sampling** instead of **Start fitting**. It becomes **Cancel fitting** while a run +is in progress; cancelling keeps the interface locked until the worker has actually +stopped, so a superseded run can never write into the parameters of the next one. + +## Reading the results + +### On the reflectivity chart + +When a run finishes, the `Reflectivity` tab of the `Analysis` page gains two extra items, +with their own legend entries: + +- **Posterior median** - the median calculated curve over the retained draws. +- **95% credible interval** - the band containing 95 % of the posterior predictive curves. + +### The Bayesian Posterior tab + +The `Analysis` page has a second main tab, **Bayesian Posterior**, holding five views. +Until a run has finished, it shows *"No Bayesian results available. Run a BUMPS-DREAM +sampling to see posterior distributions."* + + + +| View | Shows | +|---|---| +| **Marginals** | Marginal posterior distribution of each sampled parameter. | +| **Corner Plot** | Pairwise parameter correlations together with the marginals. | +| **Traces** | The MCMC chain traces, for eyeballing mixing and burn-in. | +| **2D Heatmap** | Joint posterior density of any two chosen parameters - pick them with the **X-axis** and **Y-axis** selectors. | +| **Diagnostics** | Convergence diagnostics, see below. | + +Each view has a **Save** button that writes the plot to disk. + +```{note} +The **Marginals**, **Corner Plot** and **Traces** views are rendered with `plotly`. If it is +not installed the view says so and gives the install command; the rest of the app is +unaffected. +``` + +### Diagnostics + +The **Diagnostics** view reports whether the run can be trusted: + +- **Sampling Configuration** - requested samples, burn-in steps, thinning, population + (chains), retained draws and number of parameters, as actually used by the run. +- **Acceptance Rate** of the sampler. +- **Gelman-Rubin R̂** per parameter. Values close to 1 indicate that the chains have + converged on the same distribution. + +## When results are discarded + +Posterior results describe one specific run of one specific model, so the app clears them - +the overlays, the plots and the results dialog - as soon as they would become stale: + +- when a project is created, loaded or reset, +- when a classical fit is started, +- when a new sampling run is started. + +## Limitations + +- **Polarised experiments cannot be sampled yet.** Fitting them classically is supported, + see [polarised data](./polarized_data.md), but a Bayesian run over a polarised experiment + is not available. +- Data files without an uncertainty column are sampled with zero variances, which the + sampler reports with a message. The fit is still performed, but the resulting credible + intervals should not be read as measurement uncertainties. diff --git a/docs/src/tutorials/datasets.md b/docs/src/tutorials/datasets.md new file mode 100644 index 00000000..e33983f4 --- /dev/null +++ b/docs/src/tutorials/datasets.md @@ -0,0 +1,33 @@ +# Demo datasets + +The repository ships four simulated neutron reflectometry datasets for trying out the +constraint features described in [material and model setup](./model_def.md). They live in +[`examples/datasets`](https://github.com/easyScience/EasyReflectometryApp/tree/master/examples/datasets), +together with a +[full write-up](https://github.com/easyScience/EasyReflectometryApp/blob/master/examples/datasets/README.md) +of each demo and the script that regenerates them. + +All four are ORSO `.ort` files simulated from a **known structure** with 4 % noise and 5 % +`dQ/Q` resolution, so every demo has a right answer to compare the fit against. The ground +truth is recorded in each file's header, and each header also carries the sample structure +in the ORSO model language, so the files can be opened in two ways: + +- **Model** › `Load a sample` › **Load sample from file** builds the layer stack for you, + with the true thicknesses, roughnesses and SLDs as starting values. Change those starting + values before fitting, so there is something to find. +- **Experiment** › **Load experiment(s) from file(s)** loads the reflectivity curve, which + also works with a hand-built sample. + +| Dataset | Demonstrates | +|---|---| +| `two_layer_film.ort` | A two-layer film whose total thickness is known (exactly 90 Å) - the thickness budget and the derived total film thickness. | +| `swapped_layers.ort` | A layer-ordering inequality: started from the swapped guess, the fit only recovers the truth with a `≤` constraint between the two thicknesses. | +| `ni_ti_multilayer.ort` | A `[Ti / Ni] × 8` repeating multilayer with a Bragg peak that pins the period - the **Constant period Λ** and **Conformal roughness** recipes. | +| `dppc_monolayer.ort` | A DPPC monolayer at the air/D2O interface - the surfactant recipes (equal head/tail area per molecule, solvent roughness). | + +```{note} +For `ni_ti_multilayer.ort` the loaded stack arrives flattened (8 × [Ti | Ni] becomes 16 +layers in one assembly); rebuild it as a `Repeating Multi-layer` by hand for the +constant-period demo. For `dppc_monolayer.ort` the loaded stack is the slab equivalent of +the surfactant; replace it with a `Surfactant layer` assembly for the surfactant recipes. +``` diff --git a/docs/src/tutorials/load_data.md b/docs/src/tutorials/load_data.md index dfef8b39..aea40f5c 100644 --- a/docs/src/tutorials/load_data.md +++ b/docs/src/tutorials/load_data.md @@ -15,3 +15,8 @@ When data is loaded, it is possible to change instrumental parameters that affec - **A**: Scale the data by the given value. - **B**: Set the level where data merges into the experimental background. - **C**: Instrumental resolution that percentage varies as a function of Q. + +## Polarised data +A measurement that recorded several spin channels is loaded with the second button, +`Load polarized experiment (file per channel)`, which takes one file per channel and asks +how to assign them. See [polarised data](./polarized_data.md). diff --git a/docs/src/tutorials/magnetism.md b/docs/src/tutorials/magnetism.md new file mode 100644 index 00000000..ddfb3a4b --- /dev/null +++ b/docs/src/tutorials/magnetism.md @@ -0,0 +1,88 @@ +# Magnetic layers + +A layer can be given a magnetisation, so that the two neutron spin states see different +scattering length densities. This page covers the **Model** page controls; loading and +fitting measured spin channels is described in +[polarised data](./polarized_data.md). + +```{note} +Magnetic layers can only be calculated by `refl1d`. `refnx` has no magnetism, so the app +asks to switch the project's calculation engine the first time a layer is made magnetic. +``` + +## The Magnetism group + +The **Magnetism** group is in the basic controls of the **Model** page, below the layer +editor. It is titled after the assembly currently selected in the `Layer editor`, for +example `Magnetism: Multi-layer`, and shows one row per layer of that assembly. + + + +| Column | Meaning | +|---|---| +| **No.** | Position of the layer in the assembly. | +| **Layer** | Layer name, as in the `Layer editor`. | +| **ρM/10⁻⁶Å⁻²** | Magnetic scattering length density of the layer. | +| **θM/°** | In-plane angle of the magnetic moment. | +| **Magn.** | Makes the layer magnetic. Unticking it removes the magnetisation. | + +`ρM` and `θM` are only editable once **Magn.** is ticked. + +```{note} +`θM = 270°` aligns the moment with the guide field, which produces no spin-flip +scattering. This is the value to start from for a simple saturated film. +``` + +### Switching the calculation engine + +Ticking **Magn.** while the project uses an engine that cannot model magnetism opens the +**Switch calculation engine?** dialog. Accepting it makes the layer magnetic *and* switches +the project to `refl1d` in one step - the page does not change under you. + +Switching recalculates the reflectivity and makes any existing fit result stale; the sample +and the loaded data are untouched. The engine can be switched back once no layer is +magnetic any more. + +The engine itself lives in the **Calculation engine** group of the advanced controls on the +**Model** page (and in `Analysis` › `Advanced`, see +[simple fitting](./simple_fitting.md)). Selecting an engine that cannot model magnetism +while the sample still has magnetic layers is refused, with a message on both pages. + +## Fitting ρM and θM + +`ρM` and `θM` appear in the `Analysis` parameter table like any other layer parameter, +named after their assembly and model - for example `Model Fe rho_m`. They come with default +limits, a fit checkbox and can be used in constraints. The parameter name filter accepts +`magnetic` as a keyword to show only the magnetic parameters. + +## Magnetic depth profiles + +Once at least one layer is magnetic, the **Magnetic profile** group appears in the basic +controls, below **Magnetism**. The same switches are repeated in `Analysis` › +`Advanced` › `Plot control`, and the two share one selection. + + + +- **Show ρ↑ and ρ↓** - adds the spin-up and spin-down potentials + `ρ ± ρM·cos(θM − A)` for each magnetic model to the SLD chart, dashed in the model's + colour. For non-magnetic layers the two curves collapse onto the nuclear SLD. +- **Show ρM** - the magnetic SLD profile on its own. +- **Show θM** - the in-plane moment angle, on its own right-hand axis. `θM` is only defined + where there is a moment, so the curve is drawn in pieces rather than joined across the + gaps. +- **Show R↑↑ and R↓↓** - splits each magnetic model's reflectivity into its two + non-spin-flip cross-sections on the **Model** page reflectivity chart, dashed in the + model's colour with their own legend rows. Off by default. + +The y-range of the SLD chart covers every visible curve and grows when a curve is switched +on, so `ρ + ρM` is never clipped. If no model is magnetic, the chart, its legend and the +sidebar are unchanged. + +```{note} +For a magnetic sample the plain model curve is **not** an unpolarised average - the +calculator returns the ↑↑ cross-section - so `R↑↑` is drawn on top of it. The sidebar says +so as well. +``` + +The `Analysis` reflectivity chart is unaffected by this switch: it already draws one +calculated curve per measured spin channel when the experiment is polarised. diff --git a/docs/src/tutorials/model_def.md b/docs/src/tutorials/model_def.md index 23c8effb..61d8495d 100644 --- a/docs/src/tutorials/model_def.md +++ b/docs/src/tutorials/model_def.md @@ -43,17 +43,105 @@ By clicking an assembly, the `Layer editor` is specified and changes can be made - **1**: Choose a material from materials in the `Material Editor`. - **2**+**3**: Setting the Thickness and Upper Roughness of the material in Angstrom, Å. +### Magnetism +A layer can also be given a magnetisation, in the `Magnetism` group below the layer editor. +This needs the `refl1d` calculation engine and is described in +[magnetic layers](./magnetism.md). + +## Structure view +The main window has a **Structure** tab next to **Reflectivity** showing a schematic of +the current model's layer stack: one colored box per layer, ambient medium on top, substrate +at the bottom. Boxes share a color per material (see the legend), box heights follow layer +thickness, and repeated multilayers with many repetitions are drawn once with a "× N" badge. + + + +Hover a box for its material, SLD, thickness and roughness; click it to select that layer in +the sidebar editor. The view updates immediately when the model changes, including after a fit. + +Note the view is a schematic, not a to-scale cross-section: heights are clamped so very thin +layers stay visible and very thick ones do not crowd out the rest. + ## Advanced controls -In the advanced controls, it is possible to apply a specific Q-range of interest and sample constraints on the model. +In the advanced controls, it is possible to apply a specific Q-range of interest, to choose +the calculation engine, and to constrain the parameters of the model. ![Advanced controls](./_images/sample_adv.png) - **A**: Setting min. Q value of interest. - **B**: Setting max. Q value of interest. -- **C**: Setting Q-resolution.

-- **D**: Set 1. dependent parameter. -- **E**: Set 2. dependent parameter. -- **F**: Set the mathematical operator (+,-,*,/) for **G** to apply to **E**. -- **G**: Numeric value to apply to **F**. -- **H**: Set the comparison operators (=,<,>) between **D** and **E**. -- **I**: Apply the sample constraint to the model. +- **C**: Setting Q-resolution. + +The **Calculation engine** group selects between `refnx` and `refl1d` for this project. The +same selector is available on the `Analysis` page. Only `refl1d` can model magnetic layers, +so selecting `refnx` while the sample has magnetic layers is refused - see +[magnetic layers](./magnetism.md). + +Below these sit three constraint groups, from the most specific to the most general. + +### Physics constraints +The `Physics constraints` group applies physically motivated constraints to an assembly with +one click. The list is per assembly of the current model, and only shows the recipes that +make sense for that assembly type; a recipe that cannot be applied right now is marked +`n/a` with the reason, and one that is always in force is marked `always on`. + + + +| Recipe | Effect | +|---|---| +| **Conformal roughness** | Every interface of the assembly shares the roughness of its first layer. | +| **Conformal thickness** | Every layer of the assembly shares the thickness of its first layer. | +| **Constant period Λ** | The summed thickness of the layers stays constant: the last layer absorbs whatever the others change by. | +| **Equal head/tail area per molecule** | The head layer takes the area per molecule of the tail layer (surfactant layers). | +| **Symmetric head groups** | The back head layer follows the front head layer thickness and area per molecule (bilayers). | +| **Solvent roughness follows the surfactant** | The roughness of the first layer below the surfactant follows the tail roughness. Needs **Conformal roughness**. | +| **Mixture fractions sum to 1** | Material mixtures and solvated materials keep their fractions normalised. Always on, not toggleable. | + +Each active recipe appears as a single row in the `Active Constraints` table of the +`Single constraints` group, of type `physics`, counting the parameters it ties together. + +### Single constraints +The `Single constraints` group creates numeric or symbolic relationships between individual +parameters. + + + +1. Pick the **dependent parameter** from the drop-down. +2. Pick the relation: `=`, `≤` or `≥`. +3. Type the **expression**, for example `np.sqrt(1 / sld_ni) + 4`. Use + **Insert parameter alias…** to paste the alias of another parameter rather than typing + it, and **Insert total film thickness** to use the read-only sum of all layer thicknesses + between superphase and subphase. +4. Check the **Preview** line, which shows how the constraint will read - and, for an + inequality, that numeric literals are interpreted in the dependent parameter's unit. +5. Press **Add constraint**. + +`=` ties the parameter to the expression, so it is no longer free. `≤` and `≥` against other +parameters become **inequality constraints**, which are enforced as penalties during +fitting. + +```{warning} +Inequality constraints need a BUMPS minimizer (`Analysis` › `Minimization method`). With +`lmfit` or `DFO-LS` selected the group shows a warning and fits are refused until the +minimizer is changed or the inequality is removed. A warning also appears when the current +parameter values violate an inequality, and again fits will not start until they hold. +``` + +Existing constraints are listed in the `Active Constraints` table, with columns `No.`, +`Type`, `Parameter` and `Expression`. The `Type` column tells the kinds apart - `expr` for a +plain equality, `≤ ≥` for an inequality, `physics` for a recipe from the group above, +`bound` for an upper bound and `value` for a fixed value - and each row can be removed +individually. + +### Model constraints +The `Model constraints` group ties whole models together rather than single parameters: +select two or more models and press **Constrain models parameters** to constrain all of +their matching parameters at once. This is the tool for co-refining several contrasts that +share a structure. The resulting constraints are listed in the `Model Constraints` table +below the selector and can be removed there. + + + +### Trying the constraints out +Four simulated datasets with a known ground truth are provided for exercising these +features - see [demo datasets](./datasets.md). diff --git a/docs/src/tutorials/polarized_data.md b/docs/src/tutorials/polarized_data.md new file mode 100644 index 00000000..dd67bdfc --- /dev/null +++ b/docs/src/tutorials/polarized_data.md @@ -0,0 +1,98 @@ +# Polarised data + +A polarised measurement records up to four spin channels of the same sample. The app loads +them as a single experiment, draws them separately, and fits them together against one +model. + +| Channel | Meaning | +|---|---| +| `pp` ↑↑ | non-spin-flip, both incoming and outgoing spin up | +| `mm` ↓↓ | non-spin-flip, both incoming and outgoing spin down | +| `pm` ↑↓ | spin-flip | +| `mp` ↓↑ | spin-flip | + +## Loading one file per channel + +The **Experimental data** group on the `Experiment` page has a second button, +**Load polarized experiment (file per channel)**, below the ordinary +**Load experiment(s) from file(s)**. Select one file per spin channel - the file picker +allows multiple selection - and the **Assign spin channels** dialog opens. + + + +Each file gets a channel drop-down, pre-assigned from the ORSO `polarization` header or, +failing that, from the file name. Adjust anything that came out wrong. Any number of +channels may be assigned, a single one included, and a file that should be ignored is set +to **not used**. + +The dialog refuses to continue and explains why if a channel is assigned to more than one +file, if a file is missing, or if nothing at all is assigned. + +```{note} +One resolution function is used for the whole polarised experiment, taken from the first +assigned channel. Differing per-channel resolution metadata in the other files is ignored; +the dialog says so. +``` + +If the project's calculation engine cannot model magnetism, the dialog also notes that the +channels will be loaded and displayed but not modelled until the sample has magnetic layers +and the engine is switched - see [magnetic layers](./magnetism.md). + +## Seeing the channels + +- The experiment chart draws one measured series, with its error bounds, per **visible** + channel, in a fixed palette (↑↑ `pp`, ↑↓ `pm`, ↓↑ `mp`, ↓↓ `mm`), with a per-channel + legend. +- The **Polarization channels** group in the advanced controls of the `Experiment` page + toggles which channels are drawn. At least one measured channel always stays visible. +- Experiment lists mark a polarised experiment with a `⇅N` badge, `N` being the number of + measured spin channels. +- With several experiments selected, each polarised experiment contributes one series per + visible channel, using the experiment colour as the hue base. + +## Spin asymmetry + +The spin asymmetry + +``` +SA(q) = (R↑↑ − R↓↓) / (R↑↑ + R↓↓) +``` + +is available as a **Spin asymmetry** tab next to **Reflectivity** on the `Experiment` page +(measured data only) and as a third tab of the lower panel on the `Analysis` page (measured +data plus the model). Neither tab is shown unless the experiment measured **both** +non-spin-flip channels; without one, the `Experiment` page has no tab strip at all. + + + +Error bars are propagated from the channel uncertainties. Two kinds of point are dropped, +with a note of how many: + +- points where `R↑↑ + R↓↓` is not significantly above zero - the background-dominated tail, + which would otherwise wreck the axis; +- points where the two channels do not share the same `q`. + +The axis shows the full `[−1, 1]` window and expands only if background-subtracted data go +outside it. + +## Fitting a polarised experiment + +Fitting a polarised experiment fits **all** its measured spin channels at once against the +shared model. Thickness, roughness, nuclear SLD, scale and background are common to every +channel; `ρM` and `θM` are constrained by all of them. Polarised and ordinary experiments +can be fitted together in the same refinement. + +The analysis and residual charts draw one measured/calculated pair per visible channel, +each with that channel's cross-section. A channel the model cannot calculate - a spin-flip +channel on a non-magnetic sample - shows its measured points only and contributes no +residuals. + +In the `Summary` report a polarised experiment contributes one row per measured spin +channel, named after the channel, and the report figures plot each channel in its channel +colour. + +## Limitations + +- One resolution function per polarised experiment, taken from the first assigned channel. +- Bayesian sampling of polarised experiments is not supported yet; see + [Bayesian analysis](./bayesian.md). diff --git a/docs/src/tutorials/simple_fitting.md b/docs/src/tutorials/simple_fitting.md index 63cec0c1..67fa0b49 100644 --- a/docs/src/tutorials/simple_fitting.md +++ b/docs/src/tutorials/simple_fitting.md @@ -32,3 +32,27 @@ The advanced control is then for changing the calculation engine and setting the - **B**: Setting the minimisation method for minimising variables. - **C**: Specifying the tolerance of the set minimiser. - **D**: Setting the maximum number of iterations(evaluations) for the minimiser to run. + +The calculation engine (**A**) is also available on the `Model` page, in its advanced +controls; both selectors change the same project setting. Note that only `refl1d` can model +magnetic layers, so a sample with magnetism cannot be calculated with `refnx` - see +[magnetic layers](./magnetism.md). + +The first entry of the minimiser list (**B**), `BUMPS-DREAM (Bayesian)`, does not minimise +but samples the posterior distribution of the fitted parameters; it replaces **C** and **D** +with its own sampling settings. See [Bayesian analysis](./bayesian.md). + +## Residuals +The lower panel of the `Analysis` page has three tabs: `SLD`, `Residuals` and, for a +polarised experiment that measured both non-spin-flip channels, `Spin asymmetry`. + +The `Residuals` tab plots the weighted residual `(M-E)/σ` against `q` - the difference +between the model and the experiment, in units of the measured uncertainty. A good fit +scatters evenly around zero with no structure left in it; a systematic wave or a drift means +the model is missing something. The tab has the same legend, hover, zoom, pan and reset +controls as the other charts, and its q-axis follows the logarithmic setting of the +reflectivity chart. + +With several experiments, or with a polarised experiment, the tab draws one residual curve +per visible measured series. A spin channel the model cannot calculate contributes no +residuals.