From a9660cf00388c349a0399c5156c13470ea0fef1b Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Tue, 15 Sep 2026 21:01:54 +0200 Subject: [PATCH 1/4] added arrows for magnetic moment visualization --- CHANGELOG.md | 24 +++ .../Backends/Mock/Plotting.qml | 7 + EasyReflectometryApp/Backends/Mock/Sample.qml | 9 +- .../Backends/Py/logic/layers.py | 29 ++- .../Backends/Py/logic/structure.py | 36 +++- .../Backends/Py/plotting_1d.py | 48 +++++ .../Backends/Py/py_backend.py | 10 + EasyReflectometryApp/Backends/Py/sample.py | 12 ++ .../Gui/Globals/BackendWrapper.qml | 23 +++ EasyReflectometryApp/Gui/GuideFieldLegend.qml | 43 ++++ .../Gui/MagneticProfileControl.qml | 8 + .../Gui/MagnetizationArrow.qml | 94 +++++++++ .../Gui/MagnetizationCompass.qml | 135 ++++++++++++ .../Sample/MainContent/StructureView.qml | 63 +++++- .../Sample/Sidebar/Basic/Groups/Magnetism.qml | 23 +++ EasyReflectometryApp/Gui/SldChart.qml | 195 +++++++++++++++++- EasyReflectometryApp/Gui/qmldir | 3 + docs/src/tutorials/magnetism.md | 82 ++++++++ tests/factories.py | 18 +- tests/test_logic_layers.py | 67 ++++++ tests/test_logic_structure.py | 98 +++++++++ tests/test_magnetic_display.py | 131 ++++++++++++ tests/test_py_sample.py | 66 ++++++ tests/test_qml_magnetization_arrow.py | 72 +++++++ tests/test_qml_magnetization_compass.py | 68 ++++++ tests/test_qml_structure_view.py | 31 +++ 26 files changed, 1384 insertions(+), 11 deletions(-) create mode 100644 EasyReflectometryApp/Gui/GuideFieldLegend.qml create mode 100644 EasyReflectometryApp/Gui/MagnetizationArrow.qml create mode 100644 EasyReflectometryApp/Gui/MagnetizationCompass.qml create mode 100644 tests/test_qml_magnetization_arrow.py create mode 100644 tests/test_qml_magnetization_compass.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4deec11a..f8a2712e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Unreleased +- Moment direction is now drawn, not just numbered. One convention, defined once + in the library: arrows are a top view along the surface normal, screen right is + the guide field H, and the angle drawn is φ = θM − 270° (a negative ρM points + the other way, with the signed parameter in the tooltip). A magnetic layer with + a negligible moment gets a hollow dot; a non-magnetic layer gets nothing, and a + gradient assembly - which has no single moment - gets nothing either. + - **Structure tab**: an arrow in every magnetic layer's box, between the name + and the thickness annotation, with no switch to find - attaching magnetism is + the request. Tooltips lead with φ, then θM and signed ρM, then the M∥/M⊥ split. + A magnetism edit now refreshes the boxes; previously a θM change updated the + chart while the boxes kept the old value. + - **SLD chart**: a new "Show moment arrows" switch in the Magnetic profile group + (off by default) adds a band above the chart with one arrow per magnetic layer + at its depth. The band never moves the chart's axes, follows zoom, pan and a + reversed z axis, is coloured and labelled per model (two bands at most, the + rest reported as "+N models"), and thins colliding arrows in a dense stack + with a "+n" whose tooltip lists what is hidden. + - **Magnetism group**: the selected magnetic layer's angle as a compass, with H + fixed to the right and the θM values on the rim. Dragging it sets θM in 5° + steps; it is read-only while a fit runs or θM follows a constraint. + - An "H →" reference is on screen wherever an arrow is. + - A project with no magnetic layer is unchanged: no arrows, no band, no compass, + and the same Structure and SLD layout as before. + - 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 diff --git a/EasyReflectometryApp/Backends/Mock/Plotting.qml b/EasyReflectometryApp/Backends/Mock/Plotting.qml index 0c537ac0..4cde751b 100644 --- a/EasyReflectometryApp/Backends/Mock/Plotting.qml +++ b/EasyReflectometryApp/Backends/Mock/Plotting.qml @@ -46,6 +46,7 @@ QtObject { // Magnetic depth profiles (no magnetic model in the mock) property bool anyModelHasMagnetism: false property var visibleSldCurves: ['spin_up', 'spin_down'] + property bool sldArrowsVisible: false property double sldThetaMinY: 0 property double sldThetaMaxY: 360 signal magneticProfileChanged() @@ -67,6 +68,12 @@ QtObject { function setSldCurveVisible(curve, visible) { console.debug(`setSldCurveVisible ${curve} ${visible}`) } + function getMagneticLayerMarkers(index) { + return [] + } + function setSldArrowsVisible(visible) { + console.debug(`setSldArrowsVisible ${visible}`) + } // Spin asymmetry (no polarized experiment in the mock) property bool spinAsymmetryAvailable: false diff --git a/EasyReflectometryApp/Backends/Mock/Sample.qml b/EasyReflectometryApp/Backends/Mock/Sample.qml index 409f1cc8..78ef2420 100644 --- a/EasyReflectometryApp/Backends/Mock/Sample.qml +++ b/EasyReflectometryApp/Backends/Mock/Sample.qml @@ -306,9 +306,9 @@ QtObject { // Layer magnetism (polarized analysis) readonly property bool magnetismSupported: true readonly property var layersMagnetism: [ - { 'label': 'label 1', 'magnetic': 'True', 'rho_m': '5.0', 'theta_m': '40.0' }, - { 'label': 'label 2', 'magnetic': 'False', 'rho_m': '0.0', 'theta_m': '270.0' }, - { 'label': 'label 3', 'magnetic': 'False', 'rho_m': '0.0', 'theta_m': '270.0' }, + { 'label': 'label 1', 'magnetic': 'True', 'rho_m': '5.0', 'theta_m': '40.0', 'phi': '130.0', 'editable': 'True' }, + { 'label': 'label 2', 'magnetic': 'False', 'rho_m': '0.0', 'theta_m': '270.0', 'phi': '', 'editable': '' }, + { 'label': 'label 3', 'magnetic': 'False', 'rho_m': '0.0', 'theta_m': '270.0', 'phi': '', 'editable': '' }, ] function setLayerMagneticAtIndex(index, value) { console.debug(`setLayerMagneticAtIndex ${index} ${value}`) @@ -319,6 +319,9 @@ QtObject { function setLayerThetaMAtIndex(index, value) { console.debug(`setLayerThetaMAtIndex ${index} ${value}`) } + function setLayerPhiAtIndex(index, value) { + console.debug(`setLayerPhiAtIndex ${index} ${value}`) + } // Table functions function removeLayer(value) { diff --git a/EasyReflectometryApp/Backends/Py/logic/layers.py b/EasyReflectometryApp/Backends/Py/logic/layers.py index 3a6f55da..70f1c309 100644 --- a/EasyReflectometryApp/Backends/Py/logic/layers.py +++ b/EasyReflectometryApp/Backends/Py/logic/layers.py @@ -3,6 +3,8 @@ from typing import Union from easyreflectometry import Project as ProjectLib +from easyreflectometry.project import GUIDE_FIELD_ANGLE +from easyreflectometry.project import magnetic_vector_for_layer from easyreflectometry.sample import LayerAreaPerMolecule from easyreflectometry.sample import LayerCollection from easyreflectometry.sample import LayerMagnetism @@ -224,7 +226,11 @@ def magnetism(self) -> list[dict[str, str]]: ``magnetic`` is 'True'/'False'; ``rho_m``/``theta_m`` carry the defaults of a fresh :class:`LayerMagnetism` for non-magnetic layers so the fields - show what attaching magnetism would start from. + show what attaching magnetism would start from. ``phi`` is the direction + the moment points, in degrees from the guide field - what the compass + draws - and ``editable`` whether that direction can be set by dragging + it (a constrained ``theta_m`` follows its expression, not the pointer). + Both are empty for a non-magnetic layer, which has no direction. """ rows = [] for layer in self._layers: @@ -235,6 +241,8 @@ def magnetism(self) -> list[dict[str, str]]: 'magnetic': str(magnetism is not None), 'rho_m': str(magnetism.rho_m.value if magnetism is not None else _DEFAULT_RHO_M), 'theta_m': str(magnetism.theta_m.value if magnetism is not None else _DEFAULT_THETA_M), + 'phi': '' if magnetism is None else str(magnetic_vector_for_layer(magnetism)['phi']), + 'editable': '' if magnetism is None else str(magnetism.theta_m.independent), } ) return rows @@ -287,6 +295,25 @@ def set_rho_m_at_index(self, index: int, new_value: float) -> bool: def set_theta_m_at_index(self, index: int, new_value: float) -> bool: return self._set_magnetism_value_at_index(index, 'theta_m', new_value) + def set_phi_at_index(self, index: int, new_value: float) -> bool: + """Set theta_m from a direction measured from the guide field. + + The inverse of `magnetic_vector_for_layer`, kept here so the convention + is never spelled out in QML: a negative rho_m means the parameter points + opposite to the moment the compass was dragged to. A constrained + theta_m follows its expression and is left alone. + """ + magnetism = self.magnetism_at_index(index) + if magnetism is None or not magnetism.theta_m.independent: + return False + try: + phi = float(new_value) % 360.0 + except (TypeError, ValueError): + return False + if magnetism.rho_m.value < 0: + phi = (phi + 180.0) % 360.0 + return self.set_theta_m_at_index(index, (phi + GUIDE_FIELD_ANGLE) % 360.0) + def _set_magnetism_value_at_index(self, index: int, attribute: str, new_value: float) -> bool: """Set one magnetic parameter, ignoring edits to a non-magnetic layer.""" magnetism = self.magnetism_at_index(index) diff --git a/EasyReflectometryApp/Backends/Py/logic/structure.py b/EasyReflectometryApp/Backends/Py/logic/structure.py index 2c0d6f64..dadb1b56 100644 --- a/EasyReflectometryApp/Backends/Py/logic/structure.py +++ b/EasyReflectometryApp/Backends/Py/logic/structure.py @@ -1,5 +1,7 @@ from easyreflectometry import Project as ProjectLib from easyreflectometry.model.model import COLORS +from easyreflectometry.project import MAGNETIC_MOMENT_FLOOR_FRACTION +from easyreflectometry.project import magnetic_vector_for_layer # An assembly whose expanded box count would exceed this collapses to its repeat unit MAX_EXPANDED_BOXES_PER_ASSEMBLY = 12 @@ -19,6 +21,11 @@ def flatten(project_lib: ProjectLib) -> tuple[list[dict], list[dict], float]: 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 + A magnetic layer carries, in addition, `magnetic` (True), `has_moment` and the + keys of `magnetic_vector_for_layer` (rho_m, theta_m, phi_param, phi, m, m_par, + m_perp). Every other box omits them entirely, so the view gates on + `magnetic === true` and a non-magnetic project's boxes are exactly as before. + Gradient boxes never carry them: a gradient has no assembly-level moment. - legend: distinct {label, color} pairs in stack order - total_thickness: physical total in Angstrom (collapsed repeats counted n times, caps excluded) """ @@ -28,6 +35,7 @@ def flatten(project_lib: ProjectLib) -> tuple[list[dict], list[dict], float]: sample = project_lib._models[model_index].sample colors = _ColorMap(project_lib._materials) + moment_floor = _moment_floor(sample) boxes = [] total_thickness = 0.0 @@ -45,7 +53,7 @@ def flatten(project_lib: ProjectLib) -> tuple[list[dict], list[dict], float]: 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)) + boxes.append(_layer_box(layer, assembly, assembly_index, layer_index, colors, moment_floor)) if collapsed: boxes[-len(assembly.layers)]['repetitions'] = repetitions @@ -73,9 +81,24 @@ def _value(quantity) -> float: return float(getattr(quantity, 'value', quantity)) -def _layer_box(layer, assembly, assembly_index: int, layer_index: int, colors: '_ColorMap') -> dict: +def _moment_floor(sample) -> float: + """Below this |rho_m| a layer's moment has no direction worth drawing. + + The same relative floor that masks the theta_m depth curve, so a box never + shows an arrow while the angle curve beside it is hidden. + """ + moments = [ + abs(float(layer.magnetism.rho_m.value)) + for assembly in sample + for layer in assembly.layers + if getattr(layer, 'magnetism', None) is not None + ] + return MAGNETIC_MOMENT_FLOOR_FRACTION * max(moments, default=0.0) + + +def _layer_box(layer, assembly, assembly_index: int, layer_index: int, colors: '_ColorMap', moment_floor: float) -> dict: material = layer.material - return { + box = { 'label': layer.name, 'material': material.name, 'color': colors.get(material), @@ -90,6 +113,13 @@ def _layer_box(layer, assembly, assembly_index: int, layer_index: int, colors: ' 'kind': 'layer', 'repetitions': 1, } + magnetism = getattr(layer, 'magnetism', None) + if magnetism is not None: + vector = magnetic_vector_for_layer(magnetism) + box.update(vector) + box['magnetic'] = True + box['has_moment'] = vector['m'] > moment_floor + return box def _gradient_box(assembly, assembly_index: int, colors: '_ColorMap') -> dict: diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index fd23ed7e..1fd79800 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -68,6 +68,12 @@ class Plotting1d(QObject): # collapse onto the nuclear curve, so a weakly magnetic sample still looks # like the familiar chart. rho_m/theta_m are parameter views and are opt-in. _visible_sld_curves: frozenset = frozenset({'spin_up', 'spin_down'}) + # Whether the SLD chart draws the per-layer moment arrows. A separate flag + # rather than a member of MAGNETIC_SLD_CURVES: the curve set feeds + # profile-segment lookup, series construction and the y-range, and an + # overlay with no profile dataset has no business in that pipeline. + # Class-level default for instances built without __init__ (test stubs). + _sld_arrows_visible: bool = False # Why the magnetic profiles of a magnetic model could not be computed # ('' = no failure). Class-level default for test stubs without __init__. _magnetic_profile_error: str = '' @@ -101,11 +107,13 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._visible_channels = frozenset({'pp', 'pm', 'mp', 'mm'}) # Magnetic profile curves shown on the SLD chart (both pages share it). self._visible_sld_curves = frozenset({'spin_up', 'spin_down'}) + self._sld_arrows_visible = False # Spin asymmetry per experiment index; cleared with the other plot data. self._spin_asymmetry_cache: dict = {} # Magnetic depth profiles per model index; a refl1d evaluation each, and # every chart refresh reads them several times. self._magnetic_profile_cache: dict = {} + self._magnetic_layer_marker_cache: dict = {} self._magnetic_profile_error = '' # Model cross-sections on the sample chart, and their cache (keyed by # model index and channel; cleared with the other plot data). @@ -149,6 +157,7 @@ def reset_data(self): self._residual_range_cache = None self._spin_asymmetry_cache = {} self._magnetic_profile_cache = {} + self._magnetic_layer_marker_cache = {} self._model_channel_cache = {} console.debug(IO.formatMsg('sub', 'Sample and SLD data cleared')) @@ -960,6 +969,44 @@ def getMagneticSldSegment(self, model_index: int, curve: str, segment: int) -> l return segments[segment] return [] + @Slot(int, result='QVariantList') + def getMagneticLayerMarkers(self, model_index: int) -> list: + """Where each magnetic layer of a model sits in the profile, and which way it points. + + One dict per magnetic layer (see + `Project.magnetic_layer_markers_for_model_at_index`), cached alongside + the profiles. Any lookup failure - a non-magnetic model, a stale index, + a calculator that cannot build the profile - is "nothing to draw": a + Python exception raised into a QML read aborts the process on Windows. + """ + cache = getattr(self, '_magnetic_layer_marker_cache', None) + if cache is None: + cache = self._magnetic_layer_marker_cache = {} + if model_index not in cache: + try: + cache[model_index] = self._project_lib.magnetic_layer_markers_for_model_at_index(model_index) + except (IndexError, KeyError, ValueError, NotImplementedError, AttributeError) as e: + console.debug(f'No magnetic layer markers for model {model_index}: {e}') + cache[model_index] = [] + return cache[model_index] + + @Property(bool, notify=magneticProfileChanged) + def sldArrowsVisible(self) -> bool: + """Whether the SLD chart draws the per-layer moment arrow band.""" + return bool(self._sld_arrows_visible) + + @Slot(bool) + def setSldArrowsVisible(self, visible: bool) -> None: + """Show or hide the moment arrow band on both SLD tabs. + + Deliberately does not emit `sldChartRangesChanged`: the band is an + overlay above the chart, and arrows must never move the SLD y-range. + """ + if bool(visible) == bool(self._sld_arrows_visible): + return + self._sld_arrows_visible = bool(visible) + self.magneticProfileChanged.emit() + @Property('QVariantList', notify=magneticProfileChanged) def visibleSldCurves(self) -> list: """Magnetic profile curves the user asked to see.""" @@ -1232,6 +1279,7 @@ def notifyMagneticProfileChanged(self) -> None: y-range, and moving rho_m/theta_m changes the curves themselves. """ self._magnetic_profile_cache = {} + self._magnetic_layer_marker_cache = {} # Magnetism appearing, disappearing or moving changes the model spin # cross-sections too, and they are drawn from the same notification. self._model_channel_cache = {} diff --git a/EasyReflectometryApp/Backends/Py/py_backend.py b/EasyReflectometryApp/Backends/Py/py_backend.py index 731133a8..69b790f1 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -243,6 +243,16 @@ def plottingGetMagneticSldSegment(self, model_index: int, curve: str, segment: i """Points of one piece of a magnetic profile curve.""" return self._plotting_1d.getMagneticSldSegment(model_index, curve, segment) + @Slot(int, result='QVariantList') + def plottingGetMagneticLayerMarkers(self, model_index: int) -> list: + """Per-layer moment markers of one model ([] when there are none).""" + return self._plotting_1d.getMagneticLayerMarkers(model_index) + + @Slot(bool) + def plottingSetSldArrowsVisible(self, visible: bool) -> None: + """Show or hide the moment arrow band on both SLD tabs.""" + self._plotting_1d.setSldArrowsVisible(visible) + @Slot(str, result=bool) def plottingSldCurveVisible(self, curve: str) -> bool: """Whether one magnetic profile curve is shown.""" diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 9945a011..d0b708ce 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -805,9 +805,21 @@ def setLayerThetaMAtIndex(self, index: int, new_value: float) -> None: if self._layers_logic.set_theta_m_at_index(index, new_value): self._emitMagnetismChanged() + @Slot(int, float) + def setLayerPhiAtIndex(self, index: int, new_value: float) -> None: + """Point a layer's moment at `new_value` degrees from the guide field.""" + if self._layers_logic.set_phi_at_index(index, new_value): + self._emitMagnetismChanged() + def _emitMagnetismChanged(self) -> None: """Magnetism edits change the model, its parameters and every curve.""" self._clearCacheAndEmitLayersChanged() + # The structure boxes carry the moment direction, so a theta_m or rho_m + # edit changes them without changing their number: without dropping the + # cache the Structure arrows keep pointing the old way until an + # unrelated layer edit happens to rebuild it. `externalRefreshPlot` is + # no substitute - it refreshes the charts, not the structure model. + self._clearStructureCacheAndEmit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 68c08b2c..8ffca67a 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -279,6 +279,7 @@ QtObject { function sampleSetLayerMagneticAtIndex(index, value) { activeBackend.sample.setLayerMagneticAtIndex(index, value) } function sampleSetLayerRhoMAtIndex(index, value) { activeBackend.sample.setLayerRhoMAtIndex(index, value) } function sampleSetLayerThetaMAtIndex(index, value) { activeBackend.sample.setLayerThetaMAtIndex(index, value) } + function sampleSetLayerPhiAtIndex(index, value) { activeBackend.sample.setLayerPhiAtIndex(index, value) } // Constraints readonly property var sampleEnabledParameterNames: activeBackend.sample.enabledParameterNames @@ -872,6 +873,13 @@ QtObject { return [] } } + readonly property bool plottingSldArrowsVisible: { + try { + return activeBackend.plotting.sldArrowsVisible || false + } catch (e) { + return false + } + } readonly property string plottingMagneticProfileError: { try { return activeBackend.plotting.magneticProfileError || '' @@ -931,6 +939,21 @@ QtObject { return false } } + function plottingGetMagneticLayerMarkers(index) { + try { + return activeBackend.plottingGetMagneticLayerMarkers(index) + } catch (e) { + console.warn("plottingGetMagneticLayerMarkers failed:", e) + return [] + } + } + function plottingSetSldArrowsVisible(visible) { + try { + activeBackend.plottingSetSldArrowsVisible(visible) + } catch (e) { + console.warn("plottingSetSldArrowsVisible failed:", e) + } + } function plottingSetSldCurveVisible(curve, visible) { try { activeBackend.plottingSetSldCurveVisible(curve, visible) diff --git a/EasyReflectometryApp/Gui/GuideFieldLegend.qml b/EasyReflectometryApp/Gui/GuideFieldLegend.qml new file mode 100644 index 00000000..0cec0f58 --- /dev/null +++ b/EasyReflectometryApp/Gui/GuideFieldLegend.qml @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements + +// The "H →" reference every arrow view puts on screen, so the direction the +// moment angles are measured from is visible rather than remembered. Drawn with +// the same component as the moment arrows, at phi = 0 by definition: the guide +// field *is* the zero of phi. +Row { + id: root + + property real glyphSize: EaStyle.Sizes.fontPixelSize * 1.5 + + spacing: EaStyle.Sizes.fontPixelSize * 0.25 + + EaElements.Label { + anchors.verticalCenter: parent.verticalCenter + text: "H" + color: EaStyle.Colors.themeForegroundMinor + } + + MagnetizationArrow { + anchors.verticalCenter: parent.verticalCenter + width: root.glyphSize + height: root.glyphSize + phi: 0 + color: EaStyle.Colors.themeForegroundMinor + outlineColor: "transparent" + } + + ToolTip.visible: hover.hovered + ToolTip.text: qsTr("Guide field direction. Moment angles are measured from it: θM = 270° points along H (no spin flip).") + + HoverHandler { + id: hover + } +} diff --git a/EasyReflectometryApp/Gui/MagneticProfileControl.qml b/EasyReflectometryApp/Gui/MagneticProfileControl.qml index 6f6b19bd..6512ae46 100644 --- a/EasyReflectometryApp/Gui/MagneticProfileControl.qml +++ b/EasyReflectometryApp/Gui/MagneticProfileControl.qml @@ -41,6 +41,14 @@ Column { onToggled: Globals.BackendWrapper.plottingSetSldCurveVisible('theta_m', checked) } + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingSldArrowsVisible + text: qsTr("Show moment arrows") + ToolTip.text: qsTr("One arrow per magnetic layer, above the chart, pointing the way its moment does") + onToggled: Globals.BackendWrapper.plottingSetSldArrowsVisible(checked) + } + EaElements.Label { color: EaStyle.Colors.themeForegroundMinor wrapMode: Text.WordWrap diff --git a/EasyReflectometryApp/Gui/MagnetizationArrow.qml b/EasyReflectometryApp/Gui/MagnetizationArrow.qml new file mode 100644 index 00000000..df007538 --- /dev/null +++ b/EasyReflectometryApp/Gui/MagnetizationArrow.qml @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick + +// One layer's in-plane magnetic moment, drawn as a compass arrow seen along the +// surface normal: screen +x is the guide field H, and `phi` is the physical +// moment direction in degrees counterclockwise from it (see +// `magnetic_vector_for_layer` in the library, which is where phi is defined). +// +// The screen mapping is here and NOWHERE else. phi is counterclockwise in a +// y-up frame; `Item.rotation` is clockwise, so it takes exactly one negation. +// The canvas below draws a right-pointing arrow in local coordinates and +// contains no trigonometry at all, so Canvas's y-down axis never enters the +// picture and the two corrections cannot cancel into a mirrored arrow. +// +// Drawn with Canvas rather than QtQuick.Shapes: the installer excludes the +// Shapes plugin (pyproject.toml). +Item { + id: root + + // Physical moment direction, degrees counterclockwise from the guide field. + property real phi: 0 + // False for a magnetic layer whose moment is negligible: the direction of a + // zero-length vector means nothing, so a hollow dot is drawn instead of an + // arrow. Not the same as a non-magnetic layer, which draws nothing at all. + property bool hasMoment: true + property color color: "black" + // Thin light outline so the glyph stays readable on a saturated box fill. + property color outlineColor: Qt.rgba(1, 1, 1, 0.75) + + implicitWidth: 16 + implicitHeight: implicitWidth + width: implicitWidth + height: implicitHeight + + rotation: -phi + + onPhiChanged: canvas.requestPaint() + onHasMomentChanged: canvas.requestPaint() + onColorChanged: canvas.requestPaint() + + Canvas { + id: canvas + + anchors.fill: parent + antialiasing: true + + onPaint: { + const ctx = getContext("2d") + ctx.reset() + + const cx = width / 2 + const cy = height / 2 + const half = 0.45 * width + const head = 0.34 * width + + ctx.lineJoin = "round" + ctx.lineCap = "round" + ctx.fillStyle = root.color + ctx.strokeStyle = root.outlineColor + ctx.lineWidth = 1 + + if (!root.hasMoment) { + // "Magnetic layer, no moment": a hollow dot, not an arrow. + ctx.beginPath() + ctx.arc(cx, cy, 0.16 * width, 0, 2 * Math.PI) + ctx.strokeStyle = root.color + ctx.stroke() + return + } + + // Shaft, tail at -x, tip at +x. + ctx.beginPath() + ctx.moveTo(cx - half, cy) + ctx.lineTo(cx + half - head, cy) + ctx.strokeStyle = root.color + ctx.lineWidth = Math.max(1, 0.1 * width) + ctx.stroke() + + // Head, pointing at +x. + ctx.beginPath() + ctx.moveTo(cx + half, cy) + ctx.lineTo(cx + half - head, cy - 0.5 * head) + ctx.lineTo(cx + half - head, cy + 0.5 * head) + ctx.closePath() + ctx.fill() + ctx.lineWidth = 1 + ctx.strokeStyle = root.outlineColor + ctx.stroke() + } + } +} diff --git a/EasyReflectometryApp/Gui/MagnetizationCompass.qml b/EasyReflectometryApp/Gui/MagnetizationCompass.qml new file mode 100644 index 00000000..5cde4d82 --- /dev/null +++ b/EasyReflectometryApp/Gui/MagnetizationCompass.qml @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements + +// The selected layer's moment angle as a dial rather than a number: the same +// arrow the Structure view and the SLD band draw, with the guide field H fixed +// to the right and the θM values the parameter table edits on the rim. +// +// Dragging inside the circle asks for a new direction through `phiRequested`; +// the owner decides whether to write it. The text field stays authoritative - +// this is a coarse pointer, snapped to `snapDegrees`. +Item { + id: root + + // Physical moment direction, degrees counterclockwise from H. + property real phi: 0 + // The parameter the table edits, shown alongside so the two stay connected. + property real thetaM: 270 + property bool hasMoment: true + // Whether a drag may ask for a new angle at all. + property bool editable: false + property int snapDegrees: 5 + + signal phiRequested(real phi) + + implicitWidth: EaStyle.Sizes.fontPixelSize * 6 + implicitHeight: implicitWidth + width: implicitWidth + height: implicitHeight + + readonly property real radius: Math.min(width, height) / 2 - EaStyle.Sizes.fontPixelSize + + Canvas { + id: dial + + anchors.fill: parent + antialiasing: true + + onPaint: { + const ctx = getContext("2d") + ctx.reset() + const cx = width / 2 + const cy = height / 2 + const r = root.radius + + ctx.strokeStyle = EaStyle.Colors.chartGridLine + ctx.lineWidth = 1 + ctx.beginPath() + ctx.arc(cx, cy, r, 0, 2 * Math.PI) + ctx.stroke() + + // Ticks every 90 degrees: the four cardinal points need no + // trigonometry, which keeps the angle convention out of this file. + ctx.beginPath() + ctx.moveTo(cx + r, cy); ctx.lineTo(cx + r * 0.85, cy) + ctx.moveTo(cx - r, cy); ctx.lineTo(cx - r * 0.85, cy) + ctx.moveTo(cx, cy + r); ctx.lineTo(cx, cy + r * 0.85) + ctx.moveTo(cx, cy - r); ctx.lineTo(cx, cy - r * 0.85) + ctx.stroke() + } + } + + // Rim labels: the θM the parameter table would show for that direction. + // θM = 270 is the guide field (`GUIDE_FIELD_ANGLE` in the library), and θM + // grows clockwise on screen from there. + Repeater { + model: [{theta: '270', dx: 1, dy: 0}, {theta: '0', dx: 0, dy: -1}, + {theta: '90', dx: -1, dy: 0}, {theta: '180', dx: 0, dy: 1}] + + EaElements.Label { + x: root.width / 2 + modelData.dx * root.radius * 1.18 - width / 2 + y: root.height / 2 + modelData.dy * root.radius * 1.18 - height / 2 + text: modelData.theta + color: EaStyle.Colors.themeForegroundMinor + } + } + + EaElements.Label { + x: root.width / 2 + root.radius * 0.55 + y: root.height / 2 - root.radius * 0.45 - height + text: "H" + color: EaStyle.Colors.themeForegroundMinor + } + + MagnetizationArrow { + anchors.centerIn: parent + width: 2 * root.radius * 0.9 + height: width + phi: root.phi + hasMoment: root.hasMoment + color: EaStyle.Colors.themeForegroundHovered + outlineColor: "transparent" + } + + MouseArea { + id: drag + + anchors.fill: parent + enabled: root.editable + cursorShape: root.editable ? Qt.CrossCursor : Qt.ArrowCursor + + onPositionChanged: if (pressed) root.requestFrom(mouseX, mouseY) + onPressed: root.requestFrom(mouseX, mouseY) + } + + // The one place a screen position becomes an angle: y is measured down, so + // the vertical component is negated to get a counterclockwise phi. The + // *drawing* never does this - `MagnetizationArrow` rotates instead. + function requestFrom(x, y) { + const dx = x - width / 2 + const dy = height / 2 - y + if (Math.abs(dx) < 1 && Math.abs(dy) < 1) { + return + } + const degrees = (Math.atan2(dy, dx) * 180 / Math.PI + 360) % 360 + phiRequested(Math.round(degrees / snapDegrees) * snapDegrees % 360) + } + + ToolTip.visible: hover.hovered + ToolTip.text: root.hasMoment + ? qsTr("Moment: %1° from H (θM %2°)").arg(root.phi.toFixed(1)).arg(root.thetaM.toFixed(1)) + + (root.editable ? '\n' + qsTr("Drag to set the angle (%1° steps)").arg(root.snapDegrees) + : '\n' + qsTr("θM follows a constraint or a running fit and cannot be dragged")) + : qsTr("ρM is zero: there is no direction to show") + + HoverHandler { + id: hover + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/StructureView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/StructureView.qml index 91e2914f..2af450c9 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/StructureView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/StructureView.qml @@ -4,6 +4,7 @@ import QtQuick.Controls import EasyApplication.Gui.Style as EaStyle import EasyApplication.Gui.Elements as EaElements +import Gui as Gui import Gui.Globals as Globals @@ -18,6 +19,12 @@ Rectangle { readonly property real capPx: 28 readonly property real minBoxPx: 22 readonly property real maxBoxPx: 120 + // Moment arrows: below the floor the glyph is unreadable and is dropped + // (the tooltip still carries the direction); the cap keeps it a marker + // rather than a picture. + readonly property real minGlyphPx: 12 + readonly property real maxGlyphPx: 32 + readonly property bool anyBoxMagnetic: boxes.some(box => box.magnetic === true) 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) @@ -60,6 +67,16 @@ Rectangle { color: EaStyle.Colors.themeForegroundMinor } + // The reference the arrows are measured from, on screen rather than in the + // user's memory. Only where there is an arrow to reference. + Gui.GuideFieldLegend { + z: 1 + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: EaStyle.Sizes.fontPixelSize + visible: root.anyBoxMagnetic + } + Flickable { id: flickable anchors.top: parent.top @@ -80,9 +97,21 @@ Rectangle { model: root.boxes Rectangle { + id: box + readonly property bool selected: modelData.assembly_index === Globals.BackendWrapper.sampleCurrentAssemblyIndex && modelData.layer_index === Globals.BackendWrapper.sampleCurrentLayerIndex + // Arrow zone: a square in the middle of the box, as tall as + // the box allows. It exists only for a magnetic layer, so a + // non-magnetic project keeps the two-column layout (centred + // name, right-pinned thickness) it has always had. + readonly property real gap: EaStyle.Sizes.fontPixelSize * 0.5 + readonly property real glyphPx: modelData.magnetic === true ? Math.min(height, root.maxGlyphPx) : 0 + readonly property bool showArrow: glyphPx >= root.minGlyphPx + readonly property real nameWidthLimit: showArrow ? Math.max(0, (width - glyphPx) / 2 - 2 * gap) + : width - EaStyle.Sizes.fontPixelSize + width: stack.width height: root.pixelHeight(modelData) color: root.fillColor(modelData.color) @@ -97,11 +126,26 @@ Rectangle { } EaElements.Label { - anchors.centerIn: parent + anchors.verticalCenter: parent.verticalCenter + x: box.showArrow ? box.gap : (box.width - width) / 2 visible: parent.height >= root.minBoxPx text: `${index} ${modelData.label}` elide: Text.ElideRight - width: Math.min(implicitWidth, parent.width - EaStyle.Sizes.fontPixelSize) + width: Math.min(implicitWidth, box.nameWidthLimit) + } + + // The layer's in-plane moment, in the middle column between + // the name and the thickness annotation. + Gui.MagnetizationArrow { + anchors.centerIn: parent + visible: box.showArrow + width: box.glyphPx + height: box.glyphPx + phi: modelData.phi ?? 0 + hasMoment: modelData.has_moment === true + // Not `channel_shade`: that only knows the spin channels. + // Against the box fill, a darker shade of the box colour. + color: Qt.darker(Qt.color(String(modelData.color)), 1.6) } // Thickness annotation @@ -144,6 +188,21 @@ Rectangle { ] if (modelData.repetitions > 1) lines.push(qsTr('Repeated × %1').arg(modelData.repetitions)) + if (modelData.magnetic === true) { + // Lead with the arrow's own quantity, then the + // parameters the sidebar table edits. + lines.push(modelData.has_moment + ? qsTr('Moment: %1° from H (θM %2°, ρM %3)') + .arg(modelData.phi.toFixed(1)) + .arg(modelData.theta_m.toFixed(1)) + .arg(modelData.rho_m.toFixed(3)) + : qsTr('Magnetic, no moment (ρM %1)').arg(modelData.rho_m.toFixed(3))) + lines.push(qsTr('M∥ %1 (no spin flip), M⊥ %2 (spin flip)') + .arg(modelData.m_par.toFixed(3)) + .arg(modelData.m_perp.toFixed(3))) + if (modelData.repetitions > 1) + lines.push(qsTr('The arrow is the repeat unit: every repeat shares these parameters.')) + } return lines.join('\n') } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml index 76a0be5d..ac3dcad0 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml @@ -9,6 +9,7 @@ import EasyApplication.Gui.Style as EaStyle import EasyApplication.Gui.Elements as EaElements import EasyApplication.Gui.Components as EaComponents +import Gui as Gui import Gui.Globals as Globals @@ -30,6 +31,10 @@ EaElements.GroupBox { } property string errorMessage: '' + // The row the table has selected; null while the selection outruns the list. + readonly property var currentRow: + Globals.BackendWrapper.sampleLayersMagnetism[Globals.BackendWrapper.sampleCurrentLayerIndex] ?? null + EaElements.GroupColumn { EaElements.Label { @@ -143,6 +148,24 @@ EaElements.GroupBox { } } + // The selected row's angle as a dial: the number in the θM column, and + // the arrow the Structure view and SLD band draw, side by side. Follows + // the selection the table already writes - no second "focused row". + Gui.MagnetizationCompass { + anchors.horizontalCenter: parent.horizontalCenter + visible: magnetismGroup.currentRow !== null && magnetismGroup.currentRow.magnetic === "True" + height: visible ? implicitHeight : 0 + phi: visible ? Number(magnetismGroup.currentRow.phi) : 0 + thetaM: visible ? Number(magnetismGroup.currentRow.theta_m) : 0 + hasMoment: visible && Number(magnetismGroup.currentRow.rho_m) !== 0 + // A constrained θM follows its expression, and a running fit owns + // every parameter: in both cases the dial is read-only. + editable: visible && magnetismGroup.currentRow.editable === "True" + && !Globals.BackendWrapper.analysisFittingRunning + onPhiRequested: (phi) => Globals.BackendWrapper.sampleSetLayerPhiAtIndex( + Globals.BackendWrapper.sampleCurrentLayerIndex, phi) + } + EaElements.Label { visible: magnetismGroup.supported color: EaStyle.Colors.themeForegroundMinor diff --git a/EasyReflectometryApp/Gui/SldChart.qml b/EasyReflectometryApp/Gui/SldChart.qml index d856a118..340fc464 100644 --- a/EasyReflectometryApp/Gui/SldChart.qml +++ b/EasyReflectometryApp/Gui/SldChart.qml @@ -10,6 +10,7 @@ import EasyApplication.Gui.Style as EaStyle import EasyApplication.Gui.Globals as EaGlobals import EasyApplication.Gui.Elements as EaElements +import Gui as Gui import Gui.Globals as Globals @@ -75,6 +76,45 @@ Rectangle { return {dash: Qt.SolidLine, width: 1.0, label: curve} } + // Moment arrow band (opt-in): where each magnetic layer sits in depth and + // which way its moment points, as a ribbon of compasses over the z axis. + readonly property real arrowGlyphSize: EaStyle.Sizes.fontPixelSize * 1.2 + readonly property real arrowBandHeight: EaStyle.Sizes.fontPixelSize * 1.8 + // Two bands at most: four magnetic models would otherwise eat a short + // Analysis tab. The rest are reported as a "+N models" note. + readonly property int maxArrowBands: 2 + + // One entry per drawn band, {modelIndex, label, color, markers}. Always + // assigned as a new array - mutating an array held by a `property var` + // does not notify its bindings (the CR2 legend bug). + property var arrowBands: [] + property int hiddenArrowBands: 0 + + function rebuildArrowBands() { + let bands = [] + let hidden = 0 + if (Globals.BackendWrapper.plottingSldArrowsVisible && anyModelMagnetic) { + const models = Globals.BackendWrapper.sampleModels + for (let i = 0; i < models.length; i++) { + // Only a model whose nuclear SLD line is on the chart gets a band. + if (!sldSeries[i] || !sldSeries[i].visible) { + continue + } + const markers = Globals.BackendWrapper.plottingGetMagneticLayerMarkers(i) + if (markers.length === 0) { + continue + } + if (bands.length >= maxArrowBands) { + hidden += 1 + continue + } + bands.push({modelIndex: i, label: models[i].label, color: models[i].color, markers: markers}) + } + } + arrowBands = bands + hiddenArrowBands = hidden + } + // Slight shade variations of the model colour, one per magnetic curve: the // hue still says "which model", the shade helps tell the curves apart. function magneticCurveColor(curve, baseColor) { @@ -87,11 +127,159 @@ Rectangle { return baseColor } + // The arrow band is a sibling ABOVE the ChartView, never a chart margin: + // growing the chart's own top margin would shrink plotArea, move the toolbar + // row and risk clipping it in the tight Sample split view. Its height is 0 + // unless the user asked for arrows and a magnetic model has markers, so a + // non-magnetic project's chart geometry is untouched by construction. + Item { + id: arrowOverlay + + readonly property real headerHeight: root.arrowBands.length > 0 ? EaStyle.Sizes.fontPixelSize * 1.4 : 0 + + z: 1 + anchors.top: parent.top + x: chartView.x + chartView.plotArea.x + width: chartView.plotArea.width + height: headerHeight + root.arrowBands.length * root.arrowBandHeight + + // The reference the arrows are measured from, on its own row so it can + // collide with neither an arrow nor the chart toolbar. + Gui.GuideFieldLegend { + anchors.right: parent.right + height: arrowOverlay.headerHeight + visible: root.arrowBands.length > 0 + } + + EaElements.Label { + anchors.left: parent.left + height: arrowOverlay.headerHeight + verticalAlignment: Text.AlignVCenter + visible: root.hiddenArrowBands > 0 + color: EaStyle.Colors.themeForegroundMinor + text: qsTr("+%1 models").arg(root.hiddenArrowBands) + ToolTip.text: qsTr("Only the first %1 magnetic models get an arrow band.").arg(root.maxArrowBands) + } + + Repeater { + model: root.arrowBands + + Item { + id: band + + readonly property var bandData: modelData + // Every quantity that moves an arrow: zoom, pan, resetAxes, the + // theta_m axis appearing and legend changes all move plotArea. + readonly property string geometry: [chartView.plotArea.x, chartView.plotArea.width, + root.chartAxisX.min, root.chartAxisX.max, + root.chartAxisX.reverse].join(',') + property var placed: [] + property var skipped: [] + + y: arrowOverlay.headerHeight + index * root.arrowBandHeight + width: arrowOverlay.width + height: root.arrowBandHeight + clip: true + + onGeometryChanged: Qt.callLater(place) + Component.onCompleted: place() + + // Map each marker's z centre to a pixel and thin the result: + // in a dense stack adjacent arrows collide, so an arrow closer + // than one glyph to the previous one is skipped and counted. + // Zooming in recovers it. `mapToPosition` follows + // `axisX.reverse`, so reverse-z needs no mirroring here. + function place() { + let drawn = [] + let missed = [] + let lastX = -Infinity + for (let i = 0; i < bandData.markers.length; i++) { + const marker = bandData.markers[i] + const mapped = chartView.mapToPosition(Qt.point(marker.z_center, root.chartAxisY.min)) + const x = mapped.x - chartView.plotArea.x + if (x < 0 || x > width) { + continue + } + if (x - lastX < root.arrowGlyphSize) { + missed.push(marker.label) + continue + } + lastX = x + drawn.push({x: x, marker: marker}) + } + placed = drawn + skipped = missed + } + + // Band identity: two Fe layers in two models must not look alike. + EaElements.Label { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: band.bandData.label + color: band.bandData.color + } + + Repeater { + model: band.placed + + Item { + id: glyph + + readonly property var marker: modelData.marker + + x: modelData.x - width / 2 + anchors.verticalCenter: parent.verticalCenter + width: root.arrowGlyphSize + height: root.arrowGlyphSize + + Gui.MagnetizationArrow { + anchors.fill: parent + phi: glyph.marker.phi + hasMoment: glyph.marker.has_moment + color: band.bandData.color + } + + HoverHandler { + id: arrowHover + } + + ToolTip.visible: arrowHover.hovered + ToolTip.text: marker === undefined ? '' : + [`${marker.label} — ${band.bandData.label}`, + marker.has_moment + ? qsTr("Moment: %1° from H (θM %2°, ρM %3)") + .arg(marker.phi.toFixed(1)).arg(marker.theta_m.toFixed(1)).arg(marker.rho_m.toFixed(3)) + : qsTr("Magnetic, no moment (ρM %1)").arg(marker.rho_m.toFixed(3)), + qsTr("M∥ %1 (no spin flip), M⊥ %2 (spin flip)") + .arg(marker.m_par.toFixed(3)).arg(marker.m_perp.toFixed(3)), + qsTr("z %1 to %2 Å").arg(marker.z_min.toFixed(1)).arg(marker.z_max.toFixed(1)) + ].join('\n') + } + } + + EaElements.Label { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: band.skipped.length > 0 + color: EaStyle.Colors.themeForegroundMinor + text: qsTr("+%1").arg(band.skipped.length) + + HoverHandler { + id: skippedHover + } + + ToolTip.visible: skippedHover.hovered + ToolTip.text: qsTr("Too close to draw at this zoom: %1").arg(band.skipped.join(', ')) + } + } + } + } + ChartView { id: chartView anchors.fill: parent - anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 + anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 + arrowOverlay.height anchors.margins: -12 antialiasing: true @@ -611,6 +799,11 @@ Rectangle { entry.series.append(magneticPoints[q].x, magneticPoints[q].y) } } + + // The band carries values, not just a count: a theta_m edit changes + // where the arrows point without changing how many there are, so it is + // rebuilt from the markers on every refresh rather than compared. + rebuildArrowBands() } function showMainTooltip(point, state) { diff --git a/EasyReflectometryApp/Gui/qmldir b/EasyReflectometryApp/Gui/qmldir index 018d661c..dc412d1f 100644 --- a/EasyReflectometryApp/Gui/qmldir +++ b/EasyReflectometryApp/Gui/qmldir @@ -2,6 +2,9 @@ module Gui ApplicationWindow ApplicationWindow.qml CalculationEngineControl CalculationEngineControl.qml +GuideFieldLegend GuideFieldLegend.qml +MagnetizationArrow MagnetizationArrow.qml +MagnetizationCompass MagnetizationCompass.qml MagneticProfileControl MagneticProfileControl.qml PlotControlRefLines PlotControlRefLines.qml SpinAsymmetryChart SpinAsymmetryChart.qml diff --git a/docs/src/tutorials/magnetism.md b/docs/src/tutorials/magnetism.md index ddfb3a4b..913ca839 100644 --- a/docs/src/tutorials/magnetism.md +++ b/docs/src/tutorials/magnetism.md @@ -33,6 +33,18 @@ example `Magnetism: Multi-layer`, and shows one row per layer of that assembly. scattering. This is the value to start from for a simple saturated film. ``` +### The moment compass + +Selecting a magnetic row shows a compass below the table: the same arrow the +[Structure tab](#moment-arrows-on-the-structure-tab) draws, with the guide field **H** +fixed pointing right and the `θM` values of the four cardinal directions on the rim, so +the convention is visible instead of remembered. Its tooltip gives the angle both ways - +`φ` from **H**, and the `θM` the table edits. + +Dragging inside the circle sets `θM`, snapped to 5°; the text field remains the precise +input. The compass is read-only - and says so in its tooltip - while a fit is running, or +when `θM` follows a constraint, because then the parameter is not the user's to set. + ### Switching the calculation engine Ticking **Magn.** while the project uses an engine that cannot model magnetism opens the @@ -70,6 +82,8 @@ controls, below **Magnetism**. The same switches are repeated in `Analysis` › - **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 moment arrows** - see [arrows on the SLD chart](#moment-arrows-on-the-sld-chart) + below. Off by default. - **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. @@ -86,3 +100,71 @@ 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. + +## Which way the moments point + +`θM` is a number, and a stack of numbers does not show at a glance whether a model is +collinear, canted or twisted. The app therefore draws the moment as an arrow, in a single +convention shared by every view: + +- the arrows are a **top view along the surface normal** - a compass laid over the sample; +- screen **right is the guide field H**, and the angle drawn is `φ`, measured from **H** + counterclockwise: `φ = θM − 270°`; +- a **negative `ρM`** is the same moment reversed, so the arrow points the opposite way + and the tooltip carries the signed parameter; +- a magnetic layer whose `ρM` is below 1 % of the largest one in the model gets a **hollow + dot** - "magnetic, but no moment": the direction of a zero-length vector means nothing. + A layer with no magnetism at all gets nothing. + +| `θM` | Arrow | Physics | +|---|---|---| +| 270° (default) | → along **H** | collinear, no spin flip | +| 90° | ← against **H** | collinear reversed, no spin flip | +| 0° / 180° | ↑ / ↓ | fully transverse, maximal spin flip | +| 40° | ↖ (`φ` = 130°) | canted | + +Every arrow view shows the **H →** reference on screen. Tooltips lead with `φ`, then the +`θM` and signed `ρM` the sidebar edits, then the split `M∥` / `M⊥` - the components the +non-spin-flip and spin-flip channels see. + +Arrows are constant length everywhere: they encode direction only. The magnitude is the +`ρM` curve's job, and the exact value is in the tooltip. + +(moment-arrows-on-the-structure-tab)= +### On the Structure tab + +Each magnetic layer's box gets an arrow between its name and its thickness annotation. +This needs no switch: attaching magnetism *is* the request to see it. Boxes too short for +a readable glyph drop the arrow and keep it in the tooltip, and the box layout of a +non-magnetic sample is unchanged. + +The Structure tab draws the **current model**; switch models in the header to inspect +another one. A repeating multilayer that the tab collapses to its repeat unit shows one +arrow per drawn box - the direction of the repeat unit, which every repeat shares. + +Gradient layers get no arrow. A gradient has no single moment of its own, and one +"representative" arrow would be actively misleading when its slices oppose; the `ρM(z)` +and `θM(z)` curves remain the truth for graded structures. + +(moment-arrows-on-the-sld-chart)= +### On the SLD chart + +**Show moment arrows** adds a band above the chart, one arrow per magnetic layer at its +depth - a ribbon of compasses over the `z` axis. It is off by default because that chart +is already dense. + +- The band sits *above* the plot, so it never overlaps the curves and never changes the + SLD y-range. +- Arrows follow zoom, pan and a reversed `z` axis. +- Each band is coloured and labelled with its model, so two `Fe` layers in two models are + told apart. At most two bands are drawn; further magnetic models are reported as + `+N models`. +- In a dense stack, an arrow that would collide with the previous one is skipped and + counted as `+n` at the end of the band, whose tooltip lists which layers are hidden. + Zooming in recovers them. + +```{note} +The `refl1d` calculator cannot repeat slabs that carry magnetism, so a magnetic model with +a repeating multilayer has no magnetic depth profile at all - and therefore no arrow band. +The **Magnetic profile** group reports the reason. +``` diff --git a/tests/factories.py b/tests/factories.py index dd022373..3adac500 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -10,9 +10,11 @@ def __init__(self, value): class FlaggedValueHolder(ValueHolder): - def __init__(self, value, enabled=True): + def __init__(self, value, enabled=True, independent=True): super().__init__(value) self.enabled = enabled + # As on a real Parameter: False once the value follows a constraint. + self.independent = independent class FakeMaterial: @@ -22,6 +24,14 @@ def __init__(self, name, sld=0.0, isld=0.0): self.isld = ValueHolder(isld) +class FakeLayerMagnetism: + """Mirrors LayerMagnetism: rho_m/theta_m as fittable value holders.""" + + def __init__(self, rho_m=0.0, theta_m=270.0): + self.rho_m = FlaggedValueHolder(rho_m) + self.theta_m = FlaggedValueHolder(theta_m) + + class FakeLayer: def __init__( self, @@ -33,8 +43,10 @@ def __init__( area_per_molecule=0.1, solvent_fraction=0.2, molecular_formula='formula', + magnetism=None, ): self.name = name + self.magnetism = magnetism self.material = material or FakeMaterial('Air') self.solvent = solvent or FakeMaterial('D2O') self._thickness = FlaggedValueHolder(thickness) @@ -372,6 +384,7 @@ def __init__( name for name in (calculator_interfaces or ['refnx', 'refl1d']) if name == 'refl1d' ] self.models_have_magnetism = False + self.calculator_supports_magnetism = calculator_name == 'refl1d' self.minimizer = FakeMinimizerValue(minimizer_name) self._fitter = None self.fitter = None @@ -387,6 +400,9 @@ def __init__( self.inequality_constraints = [] self.calls = [] + def _sync_parameter_states(self): + self.calls.append(('_sync_parameter_states',)) + def violated_inequality_constraints(self): return [spec for spec in self.inequality_constraints if getattr(spec, 'violated', False)] diff --git a/tests/test_logic_layers.py b/tests/test_logic_layers.py index aa2c8617..9321e53d 100644 --- a/tests/test_logic_layers.py +++ b/tests/test_logic_layers.py @@ -1,5 +1,6 @@ from EasyReflectometryApp.Backends.Py.logic import layers as layers_module from tests.factories import FakeLayerAreaPerMolecule +from tests.factories import FakeLayerMagnetism from tests.factories import make_assembly from tests.factories import make_layer from tests.factories import make_layer_collection @@ -187,3 +188,69 @@ def test_layers_index_based_setters_ignore_invalid_indices(monkeypatch): assert logic._layers[0].material.name == 'Air' assert logic._layers[0].thickness.value == 10.0 + + +# The moment compass (spin-direction design A5/A6): phi is reported per row and +# set back through the guide-field convention, which never appears in QML. + + +def _magnetism_logic(rho_m=3.0, theta_m=40.0, independent=True): + magnetism = FakeLayerMagnetism(rho_m=rho_m, theta_m=theta_m) + magnetism.theta_m.independent = independent + materials = make_material_collection(make_material('Air'), make_material('Fe')) + sample = make_sample( + make_assembly( + name='Fe', + layers=[ + make_layer(name='Plain Layer', material=materials[0]), + make_layer(name='Fe Layer', material=materials[1], magnetism=magnetism), + ], + ) + ) + project = make_project(materials=materials, models=make_model_collection(make_model(sample=sample))) + return layers_module.Layers(project), magnetism + + +def test_magnetism_rows_report_the_drawn_direction(): + logic, _ = _magnetism_logic(theta_m=40.0) + + plain, magnetic = logic.magnetism + + assert magnetic['phi'] == '130.0' # 40 deg is 130 deg from the guide field + assert magnetic['editable'] == 'True' + # A non-magnetic layer has no direction and nothing to drag. + assert (plain['phi'], plain['editable']) == ('', '') + + +def test_setting_phi_writes_theta_m_through_the_guide_field_convention(): + logic, magnetism = _magnetism_logic(theta_m=40.0) + + assert logic.set_phi_at_index(1, 0.0) is True + + assert magnetism.theta_m.value == 270.0 # phi = 0 is along the guide field + + +def test_setting_phi_on_a_negative_moment_flips_the_parameter_back(): + logic, magnetism = _magnetism_logic(rho_m=-3.0, theta_m=40.0) + + logic.set_phi_at_index(1, 0.0) + + # The moment points along H, so the parameter points the opposite way. + assert magnetism.theta_m.value == 90.0 + assert logic.magnetism[1]['phi'] == '0.0' + + +def test_a_constrained_theta_m_refuses_the_drag(): + logic, magnetism = _magnetism_logic(theta_m=40.0, independent=False) + + assert logic.set_phi_at_index(1, 0.0) is False + + assert magnetism.theta_m.value == 40.0 + assert logic.magnetism[1]['editable'] == 'False' + + +def test_setting_phi_on_a_non_magnetic_layer_is_a_no_op(): + logic, _ = _magnetism_logic() + + assert logic.set_phi_at_index(0, 90.0) is False + assert logic.set_phi_at_index(7, 90.0) is False diff --git a/tests/test_logic_structure.py b/tests/test_logic_structure.py index 5391e8e4..9b35a3d2 100644 --- a/tests/test_logic_structure.py +++ b/tests/test_logic_structure.py @@ -1,6 +1,9 @@ +import pytest + 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 FakeLayerMagnetism from tests.factories import FakeRepeatingMultilayer from tests.factories import FakeSolvatedMaterial from tests.factories import make_assembly @@ -200,3 +203,98 @@ def test_legend_lists_only_used_materials_once(): {'label': 'Air', 'color': COLORS[0]}, {'label': 'Si', 'color': COLORS[1]}, ] + + +# Moment arrows (spin-direction design A3): a magnetic layer's box carries the +# in-plane direction, every other box is untouched. + + +def _magnetic_sample(materials, rho_m=3.0, theta_m=40.0): + return make_sample( + make_assembly(name='Top', layers=[make_layer(name='Air Layer', material=materials[0], thickness=0.0)]), + make_assembly( + name='Fe', + layers=[ + make_layer( + name='Fe Layer', + material=materials[1], + thickness=40.0, + magnetism=FakeLayerMagnetism(rho_m=rho_m, theta_m=theta_m), + ) + ], + ), + make_assembly(name='Bottom', layers=[make_layer(name='Si Layer', material=materials[2], thickness=0.0)]), + ) + + +def test_a_magnetic_layer_box_carries_the_moment_direction(): + materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) + + boxes, _, _ = flatten(_project(_magnetic_sample(materials), materials)) + + magnetic = boxes[1] + assert magnetic['magnetic'] is True + assert magnetic['has_moment'] is True + assert magnetic['phi'] == pytest.approx(130.0) # theta_m 40 is 130 deg from the guide field + assert magnetic['theta_m'] == pytest.approx(40.0) + assert magnetic['rho_m'] == pytest.approx(3.0) + + +def test_non_magnetic_boxes_omit_the_arrow_keys_entirely(): + materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) + + boxes, _, _ = flatten(_project(_magnetic_sample(materials), materials)) + + for box in (boxes[0], boxes[2]): + assert 'magnetic' not in box + assert 'phi' not in box + + +def test_a_negative_rho_m_flips_the_drawn_direction(): + materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) + + positive, _, _ = flatten(_project(_magnetic_sample(materials, rho_m=3.0), materials)) + negative, _, _ = flatten(_project(_magnetic_sample(materials, rho_m=-3.0), materials)) + + assert negative[1]['phi'] == pytest.approx((positive[1]['phi'] + 180.0) % 360.0) + assert negative[1]['m'] == pytest.approx(3.0) + + +def test_a_negligible_moment_is_flagged_as_having_none(): + materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0], thickness=0.0)]), + make_assembly( + name='Strong', + layers=[make_layer(material=materials[1], thickness=40.0, magnetism=FakeLayerMagnetism(rho_m=4.0))], + ), + make_assembly( + name='Faint', + layers=[make_layer(material=materials[1], thickness=40.0, magnetism=FakeLayerMagnetism(rho_m=0.01))], + ), + make_assembly(name='Bottom', layers=[make_layer(material=materials[2], thickness=0.0)]), + ) + + boxes, _, _ = flatten(_project(sample, materials)) + + assert boxes[1]['has_moment'] is True + assert boxes[2]['has_moment'] is False # below 1 % of the largest moment + + +def test_gradient_boxes_stay_arrow_free(): + materials = make_material_collection(make_material('Air'), make_material('D2O')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0], thickness=0.0)]), + FakeGradientLayer(name='Grad', front_material=materials[0], back_material=materials[1], thickness=2.0), + make_assembly( + name='Fe', + layers=[make_layer(material=materials[1], thickness=40.0, magnetism=FakeLayerMagnetism(rho_m=3.0))], + ), + make_assembly(name='Bottom', layers=[make_layer(material=materials[1], thickness=0.0)]), + ) + + boxes, _, _ = flatten(_project(sample, materials)) + + # A gradient has no assembly-level moment vector to draw; the curves are the truth. + assert 'magnetic' not in boxes[1] + assert boxes[2]['magnetic'] is True diff --git a/tests/test_magnetic_display.py b/tests/test_magnetic_display.py index 250b84a4..c780d5f2 100644 --- a/tests/test_magnetic_display.py +++ b/tests/test_magnetic_display.py @@ -453,3 +453,134 @@ def test_import_dialog_mentions_the_engine_limitation(self): ).read_text(encoding='utf-8') assert 'sampleCalculationEnginesSupportingMagnetism' in dialog + + +class TestMomentArrows: + """Spin-direction design A4: the moment arrow band on the SLD chart.""" + + def test_off_by_default_and_absent_without_magnetism(self, qcore_application): + plotting = Plotting1d(project_lib=_plain_project(), parent=None) + + assert plotting.sldArrowsVisible is False + assert plotting.getMagneticLayerMarkers(0) == [] + # Stubs built without __init__ still have the flag. + assert Plotting1d._sld_arrows_visible is False + + def test_markers_describe_each_magnetic_layer(self, qcore_application): + plotting = Plotting1d(project_lib=_magnetic_project(theta_m=40.0), parent=None) + + markers = plotting.getMagneticLayerMarkers(0) + + assert len(markers) == 1 + assert markers[0]['phi'] == pytest.approx(130.0) + assert markers[0]['has_moment'] is True + # The extent is in the profile's own z frame, not thickness from zero. + z = plotting._magnetic_sld_profiles(0)['rho_m'].x + assert z.min() <= markers[0]['z_min'] < markers[0]['z_max'] <= z.max() + + def test_markers_are_cached_until_invalidated(self, qcore_application): + plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) + + first = plotting.getMagneticLayerMarkers(0) + assert plotting.getMagneticLayerMarkers(0) is first + + plotting.notifyMagneticProfileChanged() + + assert plotting.getMagneticLayerMarkers(0) is not first + + def test_a_stale_model_index_draws_nothing_rather_than_raising(self, qcore_application): + plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) + + assert plotting.getMagneticLayerMarkers(7) == [] + + def test_toggling_arrows_notifies_without_touching_the_sld_range(self, qcore_application): + plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) + magnetic, ranges = [], [] + plotting.magneticProfileChanged.connect(lambda: magnetic.append(True)) + plotting.sldChartRangesChanged.connect(lambda: ranges.append(True)) + + plotting.setSldArrowsVisible(True) + + assert plotting.sldArrowsVisible is True + assert magnetic == [True] + assert ranges == [] # an overlay must never move the y axis + + plotting.setSldArrowsVisible(True) + assert magnetic == [True] # idempotent + + def test_arrows_are_not_a_member_of_the_curve_set(self, qcore_application): + plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) + + plotting.setSldArrowsVisible(True) + + assert 'arrows' not in plotting.MAGNETIC_SLD_CURVES + assert 'arrows' not in plotting.visibleSldCurves + + +class TestMomentArrowsQml: + """What the QML sources must hold to (the app has no QML test harness).""" + + @staticmethod + def _chart() -> str: + return (ROOT / 'EasyReflectometryApp' / 'Gui' / 'SldChart.qml').read_text(encoding='utf-8') + + def test_the_band_is_a_sibling_overlay_that_cannot_move_the_plot_area(self): + chart = self._chart() + + # Height 0 without bands, so a non-magnetic chart keeps its geometry. + assert 'height: headerHeight + root.arrowBands.length * root.arrowBandHeight' in chart + assert 'x: chartView.x + chartView.plotArea.x' in chart + assert 'width: chartView.plotArea.width' in chart + # The ChartView is pushed down by the overlay, not given chart margins. + assert ('anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1' + ' + arrowOverlay.height') in chart + assert 'chartView.margins' not in chart + + def test_arrows_remap_on_every_geometry_change_not_only_on_new_data(self): + chart = self._chart() + + assert 'chartView.plotArea.x, chartView.plotArea.width' in chart + assert 'root.chartAxisX.min, root.chartAxisX.max' in chart + assert 'root.chartAxisX.reverse' in chart + assert 'onGeometryChanged: Qt.callLater(place)' in chart + # Reverse z follows mapToPosition; z is never mirrored by hand. + assert 'chartView.mapToPosition(Qt.point(marker.z_center' in chart + + def test_the_band_model_is_assigned_as_a_new_array(self): + chart = self._chart() + + assert 'arrowBands = bands' in chart + assert 'arrowBands.push(' not in chart + # Values change without the count changing, so it is rebuilt every refresh. + assert 'rebuildArrowBands()' in chart + + def test_dense_stacks_skip_colliding_arrows_and_say_how_many(self): + chart = self._chart() + + assert 'if (x - lastX < root.arrowGlyphSize)' in chart + assert 'missed.push(marker.label)' in chart + assert 'qsTr("Too close to draw at this zoom: %1").arg(band.skipped.join(\', \'))' in chart + + def test_bands_are_capped_and_carry_the_model_identity(self): + chart = self._chart() + + assert 'readonly property int maxArrowBands: 2' in chart + assert 'qsTr("+%1 models").arg(root.hiddenArrowBands)' in chart + assert 'color: band.bandData.color' in chart + assert 'text: band.bandData.label' in chart + + def test_the_checkbox_is_its_own_flag_and_off_by_default(self): + control = (ROOT / 'EasyReflectometryApp' / 'Gui' / 'MagneticProfileControl.qml').read_text(encoding='utf-8') + + assert 'checked: Globals.BackendWrapper.plottingSldArrowsVisible' in control + assert 'plottingSetSldArrowsVisible(checked)' in control + + def test_wrapper_and_mock_expose_the_new_contract(self): + wrapper = (ROOT / 'EasyReflectometryApp' / 'Gui' / 'Globals' / 'BackendWrapper.qml').read_text(encoding='utf-8') + mock = (ROOT / 'EasyReflectometryApp' / 'Backends' / 'Mock' / 'Plotting.qml').read_text(encoding='utf-8') + + assert 'plottingSldArrowsVisible' in wrapper + assert 'plottingSetSldArrowsVisible' in wrapper + assert 'plottingGetMagneticLayerMarkers' in wrapper + for name in ('sldArrowsVisible', 'setSldArrowsVisible', 'getMagneticLayerMarkers'): + assert name in mock diff --git a/tests/test_py_sample.py b/tests/test_py_sample.py index f7cf99e2..d92be442 100644 --- a/tests/test_py_sample.py +++ b/tests/test_py_sample.py @@ -1,4 +1,5 @@ from EasyReflectometryApp.Backends.Py.sample import Sample +from tests.factories import FakeLayerMagnetism from tests.factories import make_assembly from tests.factories import make_layer from tests.factories import make_material @@ -108,3 +109,68 @@ def test_set_current_model_index_refreshes_layers_and_selection(qcore_applicatio assert backend.currentLayerIndex == 0 assert [layer['material'] for layer in backend.layers] == ['D2O'] assert set(fired) == {'assembliesIndexChanged', 'layersIndexChanged', 'layersChange'} + + +def test_magnetism_edits_invalidate_the_structure_cache(qcore_application): + """The Structure boxes carry the moment direction, so a theta_m/rho_m edit + changes them without changing their number - the CR-Mo1 failure mode.""" + materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0], thickness=0.0)]), + make_assembly( + name='Fe', + layers=[ + make_layer( + name='Fe Layer', + material=materials[1], + thickness=40.0, + magnetism=FakeLayerMagnetism(rho_m=3.0, theta_m=270.0), + ) + ], + ), + make_assembly(name='Bottom', layers=[make_layer(material=materials[2], thickness=0.0)]), + ) + project = make_project(materials=materials, models=make_model_collection(make_model(sample=sample))) + project.current_assembly_index = 1 + backend = Sample(project) + emitted = [] + backend.structureChanged.connect(lambda: emitted.append(True)) + + assert backend.structure[1]['phi'] == 0.0 # theta_m 270 points along the guide field + + backend.setLayerThetaMAtIndex(0, 40.0) + assert emitted == [True] + assert backend.structure[1]['phi'] == 130.0 + + backend.setLayerRhoMAtIndex(0, -3.0) + assert emitted == [True, True] + assert backend.structure[1]['phi'] == 310.0 # a negative moment points the other way + assert backend.structure[1]['m'] == 3.0 + + +def test_attaching_and_detaching_magnetism_rebuilds_the_structure(qcore_application): + materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(material=materials[0], thickness=0.0)]), + make_assembly(name='Fe', layers=[make_layer(name='Fe Layer', material=materials[1], thickness=40.0)]), + make_assembly(name='Bottom', layers=[make_layer(material=materials[2], thickness=0.0)]), + ) + project = make_project( + materials=materials, + models=make_model_collection(make_model(sample=sample)), + calculator_name='refl1d', + ) + project.current_assembly_index = 1 + backend = Sample(project) + emitted = [] + backend.structureChanged.connect(lambda: emitted.append(True)) + + assert 'magnetic' not in backend.structure[1] + + backend.setLayerMagneticAtIndex(0, True) + assert emitted == [True] + assert backend.structure[1]['magnetic'] is True + + backend.setLayerMagneticAtIndex(0, False) + assert emitted == [True, True] + assert 'magnetic' not in backend.structure[1] diff --git a/tests/test_qml_magnetization_arrow.py b/tests/test_qml_magnetization_arrow.py new file mode 100644 index 00000000..1dce5f05 --- /dev/null +++ b/tests/test_qml_magnetization_arrow.py @@ -0,0 +1,72 @@ +"""Source-level assertions on the shared arrow components (no QML engine is +instantiated; rendering is verified by running the app). + +The physics convention itself - which phi an angle maps to - is pinned in the +library (`tests/test_magnetic_markers.py` there, all four cardinals plus the +canted example). What can go wrong *here* is the screen mapping: phi is +counterclockwise, Qt rotates clockwise and Canvas's y points down, so applying +both corrections silently mirrors every arrow. The contract that rules that out +is "one negation on the item, no trigonometry in the paint code", and that is +what these tests hold to. +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GUI = ROOT / 'EasyReflectometryApp' / 'Gui' +ARROW = GUI / 'MagnetizationArrow.qml' +LEGEND = GUI / 'GuideFieldLegend.qml' + + +def test_the_screen_mapping_is_a_single_negation_on_the_item(): + arrow_qml = ARROW.read_text(encoding='utf-8') + + assert 'rotation: -phi' in arrow_qml + + +def test_the_paint_code_carries_no_second_mapping(): + paint = ARROW.read_text(encoding='utf-8').split('onPaint:', 1)[1] + + # A sin/cos in the paint code would be the second half of the same + # correction and would cancel the item rotation into a mirrored arrow. + for banned in ('Math.sin', 'Math.cos', 'Math.atan', 'phi'): + assert banned not in paint, f'{banned} in the paint code is a second angle mapping' + # ... except for the hollow "no moment" dot, which is a circle, not a direction. + assert 'ctx.arc(' in paint + + +def test_arrows_are_drawn_with_canvas_because_shapes_is_not_packaged(): + arrow_qml = ARROW.read_text(encoding='utf-8') + + assert 'Canvas {' in arrow_qml + assert 'import QtQuick.Shapes' not in arrow_qml + assert 'ShapePath' not in arrow_qml + + +def test_the_arrow_repaints_when_its_inputs_change(): + arrow_qml = ARROW.read_text(encoding='utf-8') + + for handler in ('onPhiChanged', 'onHasMomentChanged', 'onColorChanged'): + assert f'{handler}: canvas.requestPaint()' in arrow_qml + + +def test_a_negligible_moment_draws_a_dot_rather_than_a_direction(): + arrow_qml = ARROW.read_text(encoding='utf-8') + + assert 'property bool hasMoment: true' in arrow_qml + assert 'if (!root.hasMoment)' in arrow_qml + + +def test_the_guide_field_legend_reuses_the_arrow_at_phi_zero(): + legend_qml = LEGEND.read_text(encoding='utf-8') + + assert 'MagnetizationArrow {' in legend_qml + assert 'phi: 0' in legend_qml + assert 'text: "H"' in legend_qml + + +def test_both_components_are_registered_in_the_gui_module(): + qmldir = (GUI / 'qmldir').read_text(encoding='utf-8') + + assert 'MagnetizationArrow MagnetizationArrow.qml' in qmldir + assert 'GuideFieldLegend GuideFieldLegend.qml' in qmldir diff --git a/tests/test_qml_magnetization_compass.py b/tests/test_qml_magnetization_compass.py new file mode 100644 index 00000000..9c93fb3e --- /dev/null +++ b/tests/test_qml_magnetization_compass.py @@ -0,0 +1,68 @@ +"""Source-level assertions on the moment compass in the Magnetism group +(spin-direction design A5/A6). No QML engine is instantiated; the angle +convention itself is pinned in the library and in `test_logic_layers.py`. +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GUI = ROOT / 'EasyReflectometryApp' / 'Gui' +COMPASS = GUI / 'MagnetizationCompass.qml' +GROUP = GUI / 'Pages' / 'Sample' / 'Sidebar' / 'Basic' / 'Groups' / 'Magnetism.qml' + + +def test_the_compass_reuses_the_shared_arrow(): + compass_qml = COMPASS.read_text(encoding='utf-8') + + assert 'MagnetizationArrow {' in compass_qml + assert 'phi: root.phi' in compass_qml + assert 'import QtQuick.Shapes' not in compass_qml + + +def test_the_compass_follows_the_selection_the_table_already_writes(): + group_qml = GROUP.read_text(encoding='utf-8') + + assert 'Globals.BackendWrapper.sampleLayersMagnetism[Globals.BackendWrapper.sampleCurrentLayerIndex]' in group_qml + assert 'Gui.MagnetizationCompass {' in group_qml + # Gone entirely - not just greyed out - for a layer with no magnetism. + assert 'visible: magnetismGroup.currentRow !== null && magnetismGroup.currentRow.magnetic === "True"' in group_qml + assert 'height: visible ? implicitHeight : 0' in group_qml + + +def test_the_rim_teaches_the_theta_m_convention(): + compass_qml = COMPASS.read_text(encoding='utf-8') + + # 270 at the guide field, growing clockwise on screen. + assert "{theta: '270', dx: 1, dy: 0}" in compass_qml + assert "{theta: '0', dx: 0, dy: -1}" in compass_qml + assert "{theta: '90', dx: -1, dy: 0}" in compass_qml + assert "{theta: '180', dx: 0, dy: 1}" in compass_qml + assert 'text: "H"' in compass_qml + + +def test_dragging_asks_for_a_snapped_phi_and_never_computes_theta_m(): + compass_qml = COMPASS.read_text(encoding='utf-8') + + assert 'signal phiRequested(real phi)' in compass_qml + assert 'property int snapDegrees: 5' in compass_qml + assert 'Math.round(degrees / snapDegrees) * snapDegrees' in compass_qml + # Converting a direction back into the parameter is the backend's job. + assert '270' not in compass_qml.split('function requestFrom', 1)[1] + + +def test_the_group_writes_through_the_backend_and_refuses_when_it_must_not(): + group_qml = GROUP.read_text(encoding='utf-8') + + assert 'sampleSetLayerPhiAtIndex' in group_qml + assert 'magnetismGroup.currentRow.editable === "True"' in group_qml + assert '!Globals.BackendWrapper.analysisFittingRunning' in group_qml + + +def test_the_backend_contract_is_mirrored_in_the_wrapper_and_the_mock(): + wrapper = (GUI / 'Globals' / 'BackendWrapper.qml').read_text(encoding='utf-8') + mock = (ROOT / 'EasyReflectometryApp' / 'Backends' / 'Mock' / 'Sample.qml').read_text(encoding='utf-8') + + assert 'sampleSetLayerPhiAtIndex' in wrapper + assert 'function setLayerPhiAtIndex(index, value)' in mock + assert "'phi': '130.0'" in mock + assert "'editable': 'True'" in mock diff --git a/tests/test_qml_structure_view.py b/tests/test_qml_structure_view.py index 25824060..2d586ebf 100644 --- a/tests/test_qml_structure_view.py +++ b/tests/test_qml_structure_view.py @@ -37,3 +37,34 @@ def test_backend_wrapper_and_mock_expose_structure_properties(): 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 + + +def test_structure_view_draws_moment_arrows_only_for_magnetic_layers(): + view_qml = (GUI / 'Pages' / 'Sample' / 'MainContent' / 'StructureView.qml').read_text(encoding='utf-8') + + # The arrow zone (and therefore the three-column layout) exists only where + # the backend marked the box magnetic; every other box is laid out as before. + assert "modelData.magnetic === true ? Math.min(height, root.maxGlyphPx) : 0" in view_qml + assert 'Gui.MagnetizationArrow {' in view_qml + assert 'visible: box.showArrow' in view_qml + assert 'phi: modelData.phi ?? 0' in view_qml + assert 'hasMoment: modelData.has_moment === true' in view_qml + # The name column yields to the arrow; the thickness column is untouched. + assert 'x: box.showArrow ? box.gap : (box.width - width) / 2' in view_qml + assert 'width: Math.min(implicitWidth, box.nameWidthLimit)' in view_qml + + +def test_guide_field_legend_is_gated_on_a_magnetic_box(): + view_qml = (GUI / 'Pages' / 'Sample' / 'MainContent' / 'StructureView.qml').read_text(encoding='utf-8') + + assert 'Gui.GuideFieldLegend {' in view_qml + assert 'visible: root.anyBoxMagnetic' in view_qml + assert 'boxes.some(box => box.magnetic === true)' in view_qml + + +def test_magnetic_tooltip_leads_with_the_angle_the_arrow_draws(): + view_qml = (GUI / 'Pages' / 'Sample' / 'MainContent' / 'StructureView.qml').read_text(encoding='utf-8') + + assert "qsTr('Moment: %1° from H (θM %2°, ρM %3)')" in view_qml + assert "qsTr('Magnetic, no moment (ρM %1)')" in view_qml + assert 'M∥ %1 (no spin flip), M⊥ %2 (spin flip)' in view_qml From 93b1a8bf16807f8eeec6a415ad03723d6cd4b096 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Tue, 15 Sep 2026 21:03:03 +0200 Subject: [PATCH 2/4] fixed reflectometry-lib branch --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fc5d1417..0add971f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ requires-python = '>=3.12' dependencies = [ 'easyapplication', - 'easyreflectometry @ git+https://github.com/easyscience/reflectometry-lib.git@develop', + 'easyreflectometry @ git+https://github.com/easyscience/reflectometry-lib.git@show-spin', #'easyreflectometry', 'asteval', 'PySide6', From 895c7e79c3e75dea1b50ea07637f16472be9dd73 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 16 Sep 2026 10:18:47 +0200 Subject: [PATCH 3/4] removed arrow from SLD plot. Fixed smaller issues --- CHANGELOG.md | 10 +- .../Backends/Mock/Plotting.qml | 7 - .../Backends/Py/plotting_1d.py | 48 ----- .../Backends/Py/py_backend.py | 10 - .../Gui/Globals/BackendWrapper.qml | 22 -- .../Gui/MagneticProfileControl.qml | 8 - EasyReflectometryApp/Gui/SldChart.qml | 195 +----------------- docs/src/tutorials/magnetism.md | 30 +-- tests/test_logic_structure.py | 17 ++ tests/test_magnetic_display.py | 131 ------------ 10 files changed, 26 insertions(+), 452 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a2712e..8c864919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,18 +11,12 @@ the request. Tooltips lead with φ, then θM and signed ρM, then the M∥/M⊥ split. A magnetism edit now refreshes the boxes; previously a θM change updated the chart while the boxes kept the old value. - - **SLD chart**: a new "Show moment arrows" switch in the Magnetic profile group - (off by default) adds a band above the chart with one arrow per magnetic layer - at its depth. The band never moves the chart's axes, follows zoom, pan and a - reversed z axis, is coloured and labelled per model (two bands at most, the - rest reported as "+N models"), and thins colliding arrows in a dense stack - with a "+n" whose tooltip lists what is hidden. - **Magnetism group**: the selected magnetic layer's angle as a compass, with H fixed to the right and the θM values on the rim. Dragging it sets θM in 5° steps; it is read-only while a fit runs or θM follows a constraint. - An "H →" reference is on screen wherever an arrow is. - - A project with no magnetic layer is unchanged: no arrows, no band, no compass, - and the same Structure and SLD layout as before. + - A project with no magnetic layer is unchanged: no arrows, no compass, and the + same Structure and SLD layout as before. - 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 diff --git a/EasyReflectometryApp/Backends/Mock/Plotting.qml b/EasyReflectometryApp/Backends/Mock/Plotting.qml index 4cde751b..0c537ac0 100644 --- a/EasyReflectometryApp/Backends/Mock/Plotting.qml +++ b/EasyReflectometryApp/Backends/Mock/Plotting.qml @@ -46,7 +46,6 @@ QtObject { // Magnetic depth profiles (no magnetic model in the mock) property bool anyModelHasMagnetism: false property var visibleSldCurves: ['spin_up', 'spin_down'] - property bool sldArrowsVisible: false property double sldThetaMinY: 0 property double sldThetaMaxY: 360 signal magneticProfileChanged() @@ -68,12 +67,6 @@ QtObject { function setSldCurveVisible(curve, visible) { console.debug(`setSldCurveVisible ${curve} ${visible}`) } - function getMagneticLayerMarkers(index) { - return [] - } - function setSldArrowsVisible(visible) { - console.debug(`setSldArrowsVisible ${visible}`) - } // Spin asymmetry (no polarized experiment in the mock) property bool spinAsymmetryAvailable: false diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index 1fd79800..fd23ed7e 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -68,12 +68,6 @@ class Plotting1d(QObject): # collapse onto the nuclear curve, so a weakly magnetic sample still looks # like the familiar chart. rho_m/theta_m are parameter views and are opt-in. _visible_sld_curves: frozenset = frozenset({'spin_up', 'spin_down'}) - # Whether the SLD chart draws the per-layer moment arrows. A separate flag - # rather than a member of MAGNETIC_SLD_CURVES: the curve set feeds - # profile-segment lookup, series construction and the y-range, and an - # overlay with no profile dataset has no business in that pipeline. - # Class-level default for instances built without __init__ (test stubs). - _sld_arrows_visible: bool = False # Why the magnetic profiles of a magnetic model could not be computed # ('' = no failure). Class-level default for test stubs without __init__. _magnetic_profile_error: str = '' @@ -107,13 +101,11 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._visible_channels = frozenset({'pp', 'pm', 'mp', 'mm'}) # Magnetic profile curves shown on the SLD chart (both pages share it). self._visible_sld_curves = frozenset({'spin_up', 'spin_down'}) - self._sld_arrows_visible = False # Spin asymmetry per experiment index; cleared with the other plot data. self._spin_asymmetry_cache: dict = {} # Magnetic depth profiles per model index; a refl1d evaluation each, and # every chart refresh reads them several times. self._magnetic_profile_cache: dict = {} - self._magnetic_layer_marker_cache: dict = {} self._magnetic_profile_error = '' # Model cross-sections on the sample chart, and their cache (keyed by # model index and channel; cleared with the other plot data). @@ -157,7 +149,6 @@ def reset_data(self): self._residual_range_cache = None self._spin_asymmetry_cache = {} self._magnetic_profile_cache = {} - self._magnetic_layer_marker_cache = {} self._model_channel_cache = {} console.debug(IO.formatMsg('sub', 'Sample and SLD data cleared')) @@ -969,44 +960,6 @@ def getMagneticSldSegment(self, model_index: int, curve: str, segment: int) -> l return segments[segment] return [] - @Slot(int, result='QVariantList') - def getMagneticLayerMarkers(self, model_index: int) -> list: - """Where each magnetic layer of a model sits in the profile, and which way it points. - - One dict per magnetic layer (see - `Project.magnetic_layer_markers_for_model_at_index`), cached alongside - the profiles. Any lookup failure - a non-magnetic model, a stale index, - a calculator that cannot build the profile - is "nothing to draw": a - Python exception raised into a QML read aborts the process on Windows. - """ - cache = getattr(self, '_magnetic_layer_marker_cache', None) - if cache is None: - cache = self._magnetic_layer_marker_cache = {} - if model_index not in cache: - try: - cache[model_index] = self._project_lib.magnetic_layer_markers_for_model_at_index(model_index) - except (IndexError, KeyError, ValueError, NotImplementedError, AttributeError) as e: - console.debug(f'No magnetic layer markers for model {model_index}: {e}') - cache[model_index] = [] - return cache[model_index] - - @Property(bool, notify=magneticProfileChanged) - def sldArrowsVisible(self) -> bool: - """Whether the SLD chart draws the per-layer moment arrow band.""" - return bool(self._sld_arrows_visible) - - @Slot(bool) - def setSldArrowsVisible(self, visible: bool) -> None: - """Show or hide the moment arrow band on both SLD tabs. - - Deliberately does not emit `sldChartRangesChanged`: the band is an - overlay above the chart, and arrows must never move the SLD y-range. - """ - if bool(visible) == bool(self._sld_arrows_visible): - return - self._sld_arrows_visible = bool(visible) - self.magneticProfileChanged.emit() - @Property('QVariantList', notify=magneticProfileChanged) def visibleSldCurves(self) -> list: """Magnetic profile curves the user asked to see.""" @@ -1279,7 +1232,6 @@ def notifyMagneticProfileChanged(self) -> None: y-range, and moving rho_m/theta_m changes the curves themselves. """ self._magnetic_profile_cache = {} - self._magnetic_layer_marker_cache = {} # Magnetism appearing, disappearing or moving changes the model spin # cross-sections too, and they are drawn from the same notification. self._model_channel_cache = {} diff --git a/EasyReflectometryApp/Backends/Py/py_backend.py b/EasyReflectometryApp/Backends/Py/py_backend.py index 69b790f1..731133a8 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -243,16 +243,6 @@ def plottingGetMagneticSldSegment(self, model_index: int, curve: str, segment: i """Points of one piece of a magnetic profile curve.""" return self._plotting_1d.getMagneticSldSegment(model_index, curve, segment) - @Slot(int, result='QVariantList') - def plottingGetMagneticLayerMarkers(self, model_index: int) -> list: - """Per-layer moment markers of one model ([] when there are none).""" - return self._plotting_1d.getMagneticLayerMarkers(model_index) - - @Slot(bool) - def plottingSetSldArrowsVisible(self, visible: bool) -> None: - """Show or hide the moment arrow band on both SLD tabs.""" - self._plotting_1d.setSldArrowsVisible(visible) - @Slot(str, result=bool) def plottingSldCurveVisible(self, curve: str) -> bool: """Whether one magnetic profile curve is shown.""" diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 8ffca67a..2582833c 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -873,13 +873,6 @@ QtObject { return [] } } - readonly property bool plottingSldArrowsVisible: { - try { - return activeBackend.plotting.sldArrowsVisible || false - } catch (e) { - return false - } - } readonly property string plottingMagneticProfileError: { try { return activeBackend.plotting.magneticProfileError || '' @@ -939,21 +932,6 @@ QtObject { return false } } - function plottingGetMagneticLayerMarkers(index) { - try { - return activeBackend.plottingGetMagneticLayerMarkers(index) - } catch (e) { - console.warn("plottingGetMagneticLayerMarkers failed:", e) - return [] - } - } - function plottingSetSldArrowsVisible(visible) { - try { - activeBackend.plottingSetSldArrowsVisible(visible) - } catch (e) { - console.warn("plottingSetSldArrowsVisible failed:", e) - } - } function plottingSetSldCurveVisible(curve, visible) { try { activeBackend.plottingSetSldCurveVisible(curve, visible) diff --git a/EasyReflectometryApp/Gui/MagneticProfileControl.qml b/EasyReflectometryApp/Gui/MagneticProfileControl.qml index 6512ae46..6f6b19bd 100644 --- a/EasyReflectometryApp/Gui/MagneticProfileControl.qml +++ b/EasyReflectometryApp/Gui/MagneticProfileControl.qml @@ -41,14 +41,6 @@ Column { onToggled: Globals.BackendWrapper.plottingSetSldCurveVisible('theta_m', checked) } - EaElements.CheckBox { - topPadding: 0 - checked: Globals.BackendWrapper.plottingSldArrowsVisible - text: qsTr("Show moment arrows") - ToolTip.text: qsTr("One arrow per magnetic layer, above the chart, pointing the way its moment does") - onToggled: Globals.BackendWrapper.plottingSetSldArrowsVisible(checked) - } - EaElements.Label { color: EaStyle.Colors.themeForegroundMinor wrapMode: Text.WordWrap diff --git a/EasyReflectometryApp/Gui/SldChart.qml b/EasyReflectometryApp/Gui/SldChart.qml index 340fc464..d856a118 100644 --- a/EasyReflectometryApp/Gui/SldChart.qml +++ b/EasyReflectometryApp/Gui/SldChart.qml @@ -10,7 +10,6 @@ import EasyApplication.Gui.Style as EaStyle import EasyApplication.Gui.Globals as EaGlobals import EasyApplication.Gui.Elements as EaElements -import Gui as Gui import Gui.Globals as Globals @@ -76,45 +75,6 @@ Rectangle { return {dash: Qt.SolidLine, width: 1.0, label: curve} } - // Moment arrow band (opt-in): where each magnetic layer sits in depth and - // which way its moment points, as a ribbon of compasses over the z axis. - readonly property real arrowGlyphSize: EaStyle.Sizes.fontPixelSize * 1.2 - readonly property real arrowBandHeight: EaStyle.Sizes.fontPixelSize * 1.8 - // Two bands at most: four magnetic models would otherwise eat a short - // Analysis tab. The rest are reported as a "+N models" note. - readonly property int maxArrowBands: 2 - - // One entry per drawn band, {modelIndex, label, color, markers}. Always - // assigned as a new array - mutating an array held by a `property var` - // does not notify its bindings (the CR2 legend bug). - property var arrowBands: [] - property int hiddenArrowBands: 0 - - function rebuildArrowBands() { - let bands = [] - let hidden = 0 - if (Globals.BackendWrapper.plottingSldArrowsVisible && anyModelMagnetic) { - const models = Globals.BackendWrapper.sampleModels - for (let i = 0; i < models.length; i++) { - // Only a model whose nuclear SLD line is on the chart gets a band. - if (!sldSeries[i] || !sldSeries[i].visible) { - continue - } - const markers = Globals.BackendWrapper.plottingGetMagneticLayerMarkers(i) - if (markers.length === 0) { - continue - } - if (bands.length >= maxArrowBands) { - hidden += 1 - continue - } - bands.push({modelIndex: i, label: models[i].label, color: models[i].color, markers: markers}) - } - } - arrowBands = bands - hiddenArrowBands = hidden - } - // Slight shade variations of the model colour, one per magnetic curve: the // hue still says "which model", the shade helps tell the curves apart. function magneticCurveColor(curve, baseColor) { @@ -127,159 +87,11 @@ Rectangle { return baseColor } - // The arrow band is a sibling ABOVE the ChartView, never a chart margin: - // growing the chart's own top margin would shrink plotArea, move the toolbar - // row and risk clipping it in the tight Sample split view. Its height is 0 - // unless the user asked for arrows and a magnetic model has markers, so a - // non-magnetic project's chart geometry is untouched by construction. - Item { - id: arrowOverlay - - readonly property real headerHeight: root.arrowBands.length > 0 ? EaStyle.Sizes.fontPixelSize * 1.4 : 0 - - z: 1 - anchors.top: parent.top - x: chartView.x + chartView.plotArea.x - width: chartView.plotArea.width - height: headerHeight + root.arrowBands.length * root.arrowBandHeight - - // The reference the arrows are measured from, on its own row so it can - // collide with neither an arrow nor the chart toolbar. - Gui.GuideFieldLegend { - anchors.right: parent.right - height: arrowOverlay.headerHeight - visible: root.arrowBands.length > 0 - } - - EaElements.Label { - anchors.left: parent.left - height: arrowOverlay.headerHeight - verticalAlignment: Text.AlignVCenter - visible: root.hiddenArrowBands > 0 - color: EaStyle.Colors.themeForegroundMinor - text: qsTr("+%1 models").arg(root.hiddenArrowBands) - ToolTip.text: qsTr("Only the first %1 magnetic models get an arrow band.").arg(root.maxArrowBands) - } - - Repeater { - model: root.arrowBands - - Item { - id: band - - readonly property var bandData: modelData - // Every quantity that moves an arrow: zoom, pan, resetAxes, the - // theta_m axis appearing and legend changes all move plotArea. - readonly property string geometry: [chartView.plotArea.x, chartView.plotArea.width, - root.chartAxisX.min, root.chartAxisX.max, - root.chartAxisX.reverse].join(',') - property var placed: [] - property var skipped: [] - - y: arrowOverlay.headerHeight + index * root.arrowBandHeight - width: arrowOverlay.width - height: root.arrowBandHeight - clip: true - - onGeometryChanged: Qt.callLater(place) - Component.onCompleted: place() - - // Map each marker's z centre to a pixel and thin the result: - // in a dense stack adjacent arrows collide, so an arrow closer - // than one glyph to the previous one is skipped and counted. - // Zooming in recovers it. `mapToPosition` follows - // `axisX.reverse`, so reverse-z needs no mirroring here. - function place() { - let drawn = [] - let missed = [] - let lastX = -Infinity - for (let i = 0; i < bandData.markers.length; i++) { - const marker = bandData.markers[i] - const mapped = chartView.mapToPosition(Qt.point(marker.z_center, root.chartAxisY.min)) - const x = mapped.x - chartView.plotArea.x - if (x < 0 || x > width) { - continue - } - if (x - lastX < root.arrowGlyphSize) { - missed.push(marker.label) - continue - } - lastX = x - drawn.push({x: x, marker: marker}) - } - placed = drawn - skipped = missed - } - - // Band identity: two Fe layers in two models must not look alike. - EaElements.Label { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: band.bandData.label - color: band.bandData.color - } - - Repeater { - model: band.placed - - Item { - id: glyph - - readonly property var marker: modelData.marker - - x: modelData.x - width / 2 - anchors.verticalCenter: parent.verticalCenter - width: root.arrowGlyphSize - height: root.arrowGlyphSize - - Gui.MagnetizationArrow { - anchors.fill: parent - phi: glyph.marker.phi - hasMoment: glyph.marker.has_moment - color: band.bandData.color - } - - HoverHandler { - id: arrowHover - } - - ToolTip.visible: arrowHover.hovered - ToolTip.text: marker === undefined ? '' : - [`${marker.label} — ${band.bandData.label}`, - marker.has_moment - ? qsTr("Moment: %1° from H (θM %2°, ρM %3)") - .arg(marker.phi.toFixed(1)).arg(marker.theta_m.toFixed(1)).arg(marker.rho_m.toFixed(3)) - : qsTr("Magnetic, no moment (ρM %1)").arg(marker.rho_m.toFixed(3)), - qsTr("M∥ %1 (no spin flip), M⊥ %2 (spin flip)") - .arg(marker.m_par.toFixed(3)).arg(marker.m_perp.toFixed(3)), - qsTr("z %1 to %2 Å").arg(marker.z_min.toFixed(1)).arg(marker.z_max.toFixed(1)) - ].join('\n') - } - } - - EaElements.Label { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - visible: band.skipped.length > 0 - color: EaStyle.Colors.themeForegroundMinor - text: qsTr("+%1").arg(band.skipped.length) - - HoverHandler { - id: skippedHover - } - - ToolTip.visible: skippedHover.hovered - ToolTip.text: qsTr("Too close to draw at this zoom: %1").arg(band.skipped.join(', ')) - } - } - } - } - ChartView { id: chartView anchors.fill: parent - anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 + arrowOverlay.height + anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 anchors.margins: -12 antialiasing: true @@ -799,11 +611,6 @@ Rectangle { entry.series.append(magneticPoints[q].x, magneticPoints[q].y) } } - - // The band carries values, not just a count: a theta_m edit changes - // where the arrows point without changing how many there are, so it is - // rebuilt from the markers on every refresh rather than compared. - rebuildArrowBands() } function showMainTooltip(point, state) { diff --git a/docs/src/tutorials/magnetism.md b/docs/src/tutorials/magnetism.md index 913ca839..2904d72a 100644 --- a/docs/src/tutorials/magnetism.md +++ b/docs/src/tutorials/magnetism.md @@ -82,8 +82,6 @@ controls, below **Magnetism**. The same switches are repeated in `Analysis` › - **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 moment arrows** - see [arrows on the SLD chart](#moment-arrows-on-the-sld-chart) - below. Off by default. - **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. @@ -92,6 +90,12 @@ The y-range of the SLD chart covers every visible curve and grows when a curve i on, so `ρ + ρM` is never clipped. If no model is magnetic, the chart, its legend and the sidebar are unchanged. +```{note} +The `refl1d` calculator cannot repeat slabs that carry magnetism, so a magnetic model with +a repeating multilayer has no magnetic depth profile at all - and therefore none of these +curves. The **Magnetic profile** group reports the reason. +``` + ```{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 @@ -146,25 +150,3 @@ Gradient layers get no arrow. A gradient has no single moment of its own, and on "representative" arrow would be actively misleading when its slices oppose; the `ρM(z)` and `θM(z)` curves remain the truth for graded structures. -(moment-arrows-on-the-sld-chart)= -### On the SLD chart - -**Show moment arrows** adds a band above the chart, one arrow per magnetic layer at its -depth - a ribbon of compasses over the `z` axis. It is off by default because that chart -is already dense. - -- The band sits *above* the plot, so it never overlaps the curves and never changes the - SLD y-range. -- Arrows follow zoom, pan and a reversed `z` axis. -- Each band is coloured and labelled with its model, so two `Fe` layers in two models are - told apart. At most two bands are drawn; further magnetic models are reported as - `+N models`. -- In a dense stack, an arrow that would collide with the previous one is skipped and - counted as `+n` at the end of the band, whose tooltip lists which layers are hidden. - Zooming in recovers them. - -```{note} -The `refl1d` calculator cannot repeat slabs that carry magnetism, so a magnetic model with -a repeating multilayer has no magnetic depth profile at all - and therefore no arrow band. -The **Magnetic profile** group reports the reason. -``` diff --git a/tests/test_logic_structure.py b/tests/test_logic_structure.py index 9b35a3d2..6c15f5ba 100644 --- a/tests/test_logic_structure.py +++ b/tests/test_logic_structure.py @@ -240,6 +240,23 @@ def test_a_magnetic_layer_box_carries_the_moment_direction(): assert magnetic['rho_m'] == pytest.approx(3.0) +def test_box_values_are_types_qml_can_read(): + """Every box value must be a builtin, never a numpy scalar. + + PySide6 hands a numpy.float64 in a QVariantList to QML as an opaque + PyObjectWrapper: `.toFixed()` on it throws, and the exception takes down + the whole binding that touched it - the layer tooltip renders empty rather + than reporting an error. + """ + materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) + + boxes, _, _ = flatten(_project(_magnetic_sample(materials), materials)) + + for box in boxes: + for key, value in box.items(): + assert type(value) in (str, int, float, bool), f'{key} is {type(value).__name__}' + + def test_non_magnetic_boxes_omit_the_arrow_keys_entirely(): materials = make_material_collection(make_material('Air'), make_material('Fe'), make_material('Si')) diff --git a/tests/test_magnetic_display.py b/tests/test_magnetic_display.py index c780d5f2..250b84a4 100644 --- a/tests/test_magnetic_display.py +++ b/tests/test_magnetic_display.py @@ -453,134 +453,3 @@ def test_import_dialog_mentions_the_engine_limitation(self): ).read_text(encoding='utf-8') assert 'sampleCalculationEnginesSupportingMagnetism' in dialog - - -class TestMomentArrows: - """Spin-direction design A4: the moment arrow band on the SLD chart.""" - - def test_off_by_default_and_absent_without_magnetism(self, qcore_application): - plotting = Plotting1d(project_lib=_plain_project(), parent=None) - - assert plotting.sldArrowsVisible is False - assert plotting.getMagneticLayerMarkers(0) == [] - # Stubs built without __init__ still have the flag. - assert Plotting1d._sld_arrows_visible is False - - def test_markers_describe_each_magnetic_layer(self, qcore_application): - plotting = Plotting1d(project_lib=_magnetic_project(theta_m=40.0), parent=None) - - markers = plotting.getMagneticLayerMarkers(0) - - assert len(markers) == 1 - assert markers[0]['phi'] == pytest.approx(130.0) - assert markers[0]['has_moment'] is True - # The extent is in the profile's own z frame, not thickness from zero. - z = plotting._magnetic_sld_profiles(0)['rho_m'].x - assert z.min() <= markers[0]['z_min'] < markers[0]['z_max'] <= z.max() - - def test_markers_are_cached_until_invalidated(self, qcore_application): - plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) - - first = plotting.getMagneticLayerMarkers(0) - assert plotting.getMagneticLayerMarkers(0) is first - - plotting.notifyMagneticProfileChanged() - - assert plotting.getMagneticLayerMarkers(0) is not first - - def test_a_stale_model_index_draws_nothing_rather_than_raising(self, qcore_application): - plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) - - assert plotting.getMagneticLayerMarkers(7) == [] - - def test_toggling_arrows_notifies_without_touching_the_sld_range(self, qcore_application): - plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) - magnetic, ranges = [], [] - plotting.magneticProfileChanged.connect(lambda: magnetic.append(True)) - plotting.sldChartRangesChanged.connect(lambda: ranges.append(True)) - - plotting.setSldArrowsVisible(True) - - assert plotting.sldArrowsVisible is True - assert magnetic == [True] - assert ranges == [] # an overlay must never move the y axis - - plotting.setSldArrowsVisible(True) - assert magnetic == [True] # idempotent - - def test_arrows_are_not_a_member_of_the_curve_set(self, qcore_application): - plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) - - plotting.setSldArrowsVisible(True) - - assert 'arrows' not in plotting.MAGNETIC_SLD_CURVES - assert 'arrows' not in plotting.visibleSldCurves - - -class TestMomentArrowsQml: - """What the QML sources must hold to (the app has no QML test harness).""" - - @staticmethod - def _chart() -> str: - return (ROOT / 'EasyReflectometryApp' / 'Gui' / 'SldChart.qml').read_text(encoding='utf-8') - - def test_the_band_is_a_sibling_overlay_that_cannot_move_the_plot_area(self): - chart = self._chart() - - # Height 0 without bands, so a non-magnetic chart keeps its geometry. - assert 'height: headerHeight + root.arrowBands.length * root.arrowBandHeight' in chart - assert 'x: chartView.x + chartView.plotArea.x' in chart - assert 'width: chartView.plotArea.width' in chart - # The ChartView is pushed down by the overlay, not given chart margins. - assert ('anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1' - ' + arrowOverlay.height') in chart - assert 'chartView.margins' not in chart - - def test_arrows_remap_on_every_geometry_change_not_only_on_new_data(self): - chart = self._chart() - - assert 'chartView.plotArea.x, chartView.plotArea.width' in chart - assert 'root.chartAxisX.min, root.chartAxisX.max' in chart - assert 'root.chartAxisX.reverse' in chart - assert 'onGeometryChanged: Qt.callLater(place)' in chart - # Reverse z follows mapToPosition; z is never mirrored by hand. - assert 'chartView.mapToPosition(Qt.point(marker.z_center' in chart - - def test_the_band_model_is_assigned_as_a_new_array(self): - chart = self._chart() - - assert 'arrowBands = bands' in chart - assert 'arrowBands.push(' not in chart - # Values change without the count changing, so it is rebuilt every refresh. - assert 'rebuildArrowBands()' in chart - - def test_dense_stacks_skip_colliding_arrows_and_say_how_many(self): - chart = self._chart() - - assert 'if (x - lastX < root.arrowGlyphSize)' in chart - assert 'missed.push(marker.label)' in chart - assert 'qsTr("Too close to draw at this zoom: %1").arg(band.skipped.join(\', \'))' in chart - - def test_bands_are_capped_and_carry_the_model_identity(self): - chart = self._chart() - - assert 'readonly property int maxArrowBands: 2' in chart - assert 'qsTr("+%1 models").arg(root.hiddenArrowBands)' in chart - assert 'color: band.bandData.color' in chart - assert 'text: band.bandData.label' in chart - - def test_the_checkbox_is_its_own_flag_and_off_by_default(self): - control = (ROOT / 'EasyReflectometryApp' / 'Gui' / 'MagneticProfileControl.qml').read_text(encoding='utf-8') - - assert 'checked: Globals.BackendWrapper.plottingSldArrowsVisible' in control - assert 'plottingSetSldArrowsVisible(checked)' in control - - def test_wrapper_and_mock_expose_the_new_contract(self): - wrapper = (ROOT / 'EasyReflectometryApp' / 'Gui' / 'Globals' / 'BackendWrapper.qml').read_text(encoding='utf-8') - mock = (ROOT / 'EasyReflectometryApp' / 'Backends' / 'Mock' / 'Plotting.qml').read_text(encoding='utf-8') - - assert 'plottingSldArrowsVisible' in wrapper - assert 'plottingSetSldArrowsVisible' in wrapper - assert 'plottingGetMagneticLayerMarkers' in wrapper - for name in ('sldArrowsVisible', 'setSldArrowsVisible', 'getMagneticLayerMarkers'): - assert name in mock From 00b5f532bf32eb82fc5c373a93e1ee8f0717c11f Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 16 Sep 2026 14:33:45 +0200 Subject: [PATCH 4/4] replace circular control with a slider --- CHANGELOG.md | 9 +- EasyReflectometryApp/Backends/Mock/Sample.qml | 3 - .../Backends/Py/logic/layers.py | 20 --- EasyReflectometryApp/Backends/Py/sample.py | 6 - .../Gui/Globals/BackendWrapper.qml | 1 - .../Gui/MagnetizationAngleSlider.qml | 115 +++++++++++++++ .../Gui/MagnetizationCompass.qml | 135 ------------------ .../Sample/Sidebar/Basic/Groups/Magnetism.qml | 13 +- EasyReflectometryApp/Gui/qmldir | 2 +- docs/src/tutorials/magnetism.md | 22 +-- tests/test_logic_layers.py | 34 +---- tests/test_qml_magnetization_compass.py | 68 --------- tests/test_qml_magnetization_slider.py | 90 ++++++++++++ 13 files changed, 237 insertions(+), 281 deletions(-) create mode 100644 EasyReflectometryApp/Gui/MagnetizationAngleSlider.qml delete mode 100644 EasyReflectometryApp/Gui/MagnetizationCompass.qml delete mode 100644 tests/test_qml_magnetization_compass.py create mode 100644 tests/test_qml_magnetization_slider.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c864919..d9d8a166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,12 @@ the request. Tooltips lead with φ, then θM and signed ρM, then the M∥/M⊥ split. A magnetism edit now refreshes the boxes; previously a θM change updated the chart while the boxes kept the old value. - - **Magnetism group**: the selected magnetic layer's angle as a compass, with H - fixed to the right and the θM values on the rim. Dragging it sets θM in 5° - steps; it is read-only while a fit runs or θM follows a constraint. + - **Magnetism group**: the selected magnetic layer's angle as a 0-360° slider, + with the H reference and the resulting arrow beside it. Dragging it sets θM + in 5° steps - the same write the θM column makes - and it is read-only while + a fit runs or θM follows a constraint. - An "H →" reference is on screen wherever an arrow is. - - A project with no magnetic layer is unchanged: no arrows, no compass, and the + - A project with no magnetic layer is unchanged: no arrows, no slider, and the same Structure and SLD layout as before. - Added a **Structure** tab on the Model page: a schematic view of the layer stack with one diff --git a/EasyReflectometryApp/Backends/Mock/Sample.qml b/EasyReflectometryApp/Backends/Mock/Sample.qml index 78ef2420..8239a2b9 100644 --- a/EasyReflectometryApp/Backends/Mock/Sample.qml +++ b/EasyReflectometryApp/Backends/Mock/Sample.qml @@ -319,9 +319,6 @@ QtObject { function setLayerThetaMAtIndex(index, value) { console.debug(`setLayerThetaMAtIndex ${index} ${value}`) } - function setLayerPhiAtIndex(index, value) { - console.debug(`setLayerPhiAtIndex ${index} ${value}`) - } // Table functions function removeLayer(value) { diff --git a/EasyReflectometryApp/Backends/Py/logic/layers.py b/EasyReflectometryApp/Backends/Py/logic/layers.py index 70f1c309..c326f109 100644 --- a/EasyReflectometryApp/Backends/Py/logic/layers.py +++ b/EasyReflectometryApp/Backends/Py/logic/layers.py @@ -3,7 +3,6 @@ from typing import Union from easyreflectometry import Project as ProjectLib -from easyreflectometry.project import GUIDE_FIELD_ANGLE from easyreflectometry.project import magnetic_vector_for_layer from easyreflectometry.sample import LayerAreaPerMolecule from easyreflectometry.sample import LayerCollection @@ -295,25 +294,6 @@ def set_rho_m_at_index(self, index: int, new_value: float) -> bool: def set_theta_m_at_index(self, index: int, new_value: float) -> bool: return self._set_magnetism_value_at_index(index, 'theta_m', new_value) - def set_phi_at_index(self, index: int, new_value: float) -> bool: - """Set theta_m from a direction measured from the guide field. - - The inverse of `magnetic_vector_for_layer`, kept here so the convention - is never spelled out in QML: a negative rho_m means the parameter points - opposite to the moment the compass was dragged to. A constrained - theta_m follows its expression and is left alone. - """ - magnetism = self.magnetism_at_index(index) - if magnetism is None or not magnetism.theta_m.independent: - return False - try: - phi = float(new_value) % 360.0 - except (TypeError, ValueError): - return False - if magnetism.rho_m.value < 0: - phi = (phi + 180.0) % 360.0 - return self.set_theta_m_at_index(index, (phi + GUIDE_FIELD_ANGLE) % 360.0) - def _set_magnetism_value_at_index(self, index: int, attribute: str, new_value: float) -> bool: """Set one magnetic parameter, ignoring edits to a non-magnetic layer.""" magnetism = self.magnetism_at_index(index) diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index d0b708ce..07e62e32 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -805,12 +805,6 @@ def setLayerThetaMAtIndex(self, index: int, new_value: float) -> None: if self._layers_logic.set_theta_m_at_index(index, new_value): self._emitMagnetismChanged() - @Slot(int, float) - def setLayerPhiAtIndex(self, index: int, new_value: float) -> None: - """Point a layer's moment at `new_value` degrees from the guide field.""" - if self._layers_logic.set_phi_at_index(index, new_value): - self._emitMagnetismChanged() - def _emitMagnetismChanged(self) -> None: """Magnetism edits change the model, its parameters and every curve.""" self._clearCacheAndEmitLayersChanged() diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 2582833c..68c08b2c 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -279,7 +279,6 @@ QtObject { function sampleSetLayerMagneticAtIndex(index, value) { activeBackend.sample.setLayerMagneticAtIndex(index, value) } function sampleSetLayerRhoMAtIndex(index, value) { activeBackend.sample.setLayerRhoMAtIndex(index, value) } function sampleSetLayerThetaMAtIndex(index, value) { activeBackend.sample.setLayerThetaMAtIndex(index, value) } - function sampleSetLayerPhiAtIndex(index, value) { activeBackend.sample.setLayerPhiAtIndex(index, value) } // Constraints readonly property var sampleEnabledParameterNames: activeBackend.sample.enabledParameterNames diff --git a/EasyReflectometryApp/Gui/MagnetizationAngleSlider.qml b/EasyReflectometryApp/Gui/MagnetizationAngleSlider.qml new file mode 100644 index 00000000..a0152af7 --- /dev/null +++ b/EasyReflectometryApp/Gui/MagnetizationAngleSlider.qml @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements + +// The selected layer's moment angle as a slider across the full 0-360° range, +// with the guide field reference and the resulting arrow beside it. +// +// The slider edits θM - the parameter the table shows and a fit varies - so it +// writes exactly what the θM text field writes, and 0-360 is that parameter's +// own range. The arrow draws φ, the direction the moment *physically* points, +// which the backend derives: a negative ρM is the same moment reversed, so the +// arrow can turn while θM stays where it was put. +Row { + id: root + + // The parameter being edited, in degrees. + property real thetaM: 270 + // Physical moment direction, degrees counterclockwise from H. Display only. + property real phi: 0 + property bool hasMoment: true + // Whether the slider may be moved at all. + property bool editable: false + // The drag resolution, as on the dial this replaces. + property int snapDegrees: 5 + + signal thetaMRequested(real thetaM) + + readonly property real glyphSize: EaStyle.Sizes.fontPixelSize * 1.5 + + width: EaStyle.Sizes.sideBarContentWidth + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.Label { + id: nameLabel + anchors.verticalCenter: parent.verticalCenter + width: EaStyle.Sizes.fontPixelSize * 2 + text: qsTr("θM") + } + + EaElements.Slider { + id: slider + + anchors.verticalCenter: parent.verticalCenter + // Whatever the fixed columns do not take. + width: root.width - nameLabel.width - valueLabel.width - guideField.width - root.glyphSize + - 4 * root.spacing + + from: 0 + to: 360 + stepSize: root.snapDegrees + // stepSize alone only snaps the keyboard and the wheel; the dial this + // replaces snapped the drag too. + snapMode: Slider.SnapAlways + enabled: root.editable + + onMoved: root.thetaMRequested(value) + } + + // The model owns the value; a drag only borrows it. Not a plain `value:` + // binding: dragging assigns `value` imperatively and would break it for + // good, after which the handle stops following the model. A Binding stands + // aside while the handle is held and reasserts itself on release, so a + // write the model refused or clamped snaps the handle back instead of + // leaving it showing an angle the layer does not have. + Binding { + target: slider + property: "value" + value: root.thetaM + when: !slider.pressed + restoreMode: Binding.RestoreNone + } + + EaElements.Label { + id: valueLabel + anchors.verticalCenter: parent.verticalCenter + width: EaStyle.Sizes.fontPixelSize * 3.5 + horizontalAlignment: Text.AlignRight + text: root.thetaM.toFixed(1) + "°" + color: EaStyle.Colors.themeForegroundMinor + } + + // The reference the arrow is measured from, next to the arrow itself. + GuideFieldLegend { + id: guideField + anchors.verticalCenter: parent.verticalCenter + glyphSize: root.glyphSize + } + + MagnetizationArrow { + anchors.verticalCenter: parent.verticalCenter + width: root.glyphSize + height: root.glyphSize + phi: root.phi + hasMoment: root.hasMoment + color: EaStyle.Colors.themeForegroundHovered + outlineColor: "transparent" + } + + ToolTip.visible: hover.hovered + ToolTip.text: root.hasMoment + ? qsTr("Moment: %1° from H (θM %2°)").arg(root.phi.toFixed(1)).arg(root.thetaM.toFixed(1)) + + (root.editable ? '\n' + qsTr("Drag to set θM (%1° steps)").arg(root.snapDegrees) + : '\n' + qsTr("θM follows a constraint or a running fit and cannot be dragged")) + : qsTr("ρM is zero: there is no direction to show") + + HoverHandler { + id: hover + } +} diff --git a/EasyReflectometryApp/Gui/MagnetizationCompass.qml b/EasyReflectometryApp/Gui/MagnetizationCompass.qml deleted file mode 100644 index 5cde4d82..00000000 --- a/EasyReflectometryApp/Gui/MagnetizationCompass.qml +++ /dev/null @@ -1,135 +0,0 @@ -// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors -// SPDX-License-Identifier: BSD-3-Clause -// © 2026 Contributors to the EasyReflectometry project - -import QtQuick -import QtQuick.Controls - -import EasyApplication.Gui.Style as EaStyle -import EasyApplication.Gui.Elements as EaElements - -// The selected layer's moment angle as a dial rather than a number: the same -// arrow the Structure view and the SLD band draw, with the guide field H fixed -// to the right and the θM values the parameter table edits on the rim. -// -// Dragging inside the circle asks for a new direction through `phiRequested`; -// the owner decides whether to write it. The text field stays authoritative - -// this is a coarse pointer, snapped to `snapDegrees`. -Item { - id: root - - // Physical moment direction, degrees counterclockwise from H. - property real phi: 0 - // The parameter the table edits, shown alongside so the two stay connected. - property real thetaM: 270 - property bool hasMoment: true - // Whether a drag may ask for a new angle at all. - property bool editable: false - property int snapDegrees: 5 - - signal phiRequested(real phi) - - implicitWidth: EaStyle.Sizes.fontPixelSize * 6 - implicitHeight: implicitWidth - width: implicitWidth - height: implicitHeight - - readonly property real radius: Math.min(width, height) / 2 - EaStyle.Sizes.fontPixelSize - - Canvas { - id: dial - - anchors.fill: parent - antialiasing: true - - onPaint: { - const ctx = getContext("2d") - ctx.reset() - const cx = width / 2 - const cy = height / 2 - const r = root.radius - - ctx.strokeStyle = EaStyle.Colors.chartGridLine - ctx.lineWidth = 1 - ctx.beginPath() - ctx.arc(cx, cy, r, 0, 2 * Math.PI) - ctx.stroke() - - // Ticks every 90 degrees: the four cardinal points need no - // trigonometry, which keeps the angle convention out of this file. - ctx.beginPath() - ctx.moveTo(cx + r, cy); ctx.lineTo(cx + r * 0.85, cy) - ctx.moveTo(cx - r, cy); ctx.lineTo(cx - r * 0.85, cy) - ctx.moveTo(cx, cy + r); ctx.lineTo(cx, cy + r * 0.85) - ctx.moveTo(cx, cy - r); ctx.lineTo(cx, cy - r * 0.85) - ctx.stroke() - } - } - - // Rim labels: the θM the parameter table would show for that direction. - // θM = 270 is the guide field (`GUIDE_FIELD_ANGLE` in the library), and θM - // grows clockwise on screen from there. - Repeater { - model: [{theta: '270', dx: 1, dy: 0}, {theta: '0', dx: 0, dy: -1}, - {theta: '90', dx: -1, dy: 0}, {theta: '180', dx: 0, dy: 1}] - - EaElements.Label { - x: root.width / 2 + modelData.dx * root.radius * 1.18 - width / 2 - y: root.height / 2 + modelData.dy * root.radius * 1.18 - height / 2 - text: modelData.theta - color: EaStyle.Colors.themeForegroundMinor - } - } - - EaElements.Label { - x: root.width / 2 + root.radius * 0.55 - y: root.height / 2 - root.radius * 0.45 - height - text: "H" - color: EaStyle.Colors.themeForegroundMinor - } - - MagnetizationArrow { - anchors.centerIn: parent - width: 2 * root.radius * 0.9 - height: width - phi: root.phi - hasMoment: root.hasMoment - color: EaStyle.Colors.themeForegroundHovered - outlineColor: "transparent" - } - - MouseArea { - id: drag - - anchors.fill: parent - enabled: root.editable - cursorShape: root.editable ? Qt.CrossCursor : Qt.ArrowCursor - - onPositionChanged: if (pressed) root.requestFrom(mouseX, mouseY) - onPressed: root.requestFrom(mouseX, mouseY) - } - - // The one place a screen position becomes an angle: y is measured down, so - // the vertical component is negated to get a counterclockwise phi. The - // *drawing* never does this - `MagnetizationArrow` rotates instead. - function requestFrom(x, y) { - const dx = x - width / 2 - const dy = height / 2 - y - if (Math.abs(dx) < 1 && Math.abs(dy) < 1) { - return - } - const degrees = (Math.atan2(dy, dx) * 180 / Math.PI + 360) % 360 - phiRequested(Math.round(degrees / snapDegrees) * snapDegrees % 360) - } - - ToolTip.visible: hover.hovered - ToolTip.text: root.hasMoment - ? qsTr("Moment: %1° from H (θM %2°)").arg(root.phi.toFixed(1)).arg(root.thetaM.toFixed(1)) - + (root.editable ? '\n' + qsTr("Drag to set the angle (%1° steps)").arg(root.snapDegrees) - : '\n' + qsTr("θM follows a constraint or a running fit and cannot be dragged")) - : qsTr("ρM is zero: there is no direction to show") - - HoverHandler { - id: hover - } -} diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml index ac3dcad0..5064a24a 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml @@ -148,22 +148,21 @@ EaElements.GroupBox { } } - // The selected row's angle as a dial: the number in the θM column, and - // the arrow the Structure view and SLD band draw, side by side. Follows + // The selected row's angle as a slider: the same number the θM column + // holds, and the arrow the Structure view draws, side by side. Follows // the selection the table already writes - no second "focused row". - Gui.MagnetizationCompass { - anchors.horizontalCenter: parent.horizontalCenter + Gui.MagnetizationAngleSlider { visible: magnetismGroup.currentRow !== null && magnetismGroup.currentRow.magnetic === "True" height: visible ? implicitHeight : 0 phi: visible ? Number(magnetismGroup.currentRow.phi) : 0 thetaM: visible ? Number(magnetismGroup.currentRow.theta_m) : 0 hasMoment: visible && Number(magnetismGroup.currentRow.rho_m) !== 0 // A constrained θM follows its expression, and a running fit owns - // every parameter: in both cases the dial is read-only. + // every parameter: in both cases the slider is read-only. editable: visible && magnetismGroup.currentRow.editable === "True" && !Globals.BackendWrapper.analysisFittingRunning - onPhiRequested: (phi) => Globals.BackendWrapper.sampleSetLayerPhiAtIndex( - Globals.BackendWrapper.sampleCurrentLayerIndex, phi) + onThetaMRequested: (thetaM) => Globals.BackendWrapper.sampleSetLayerThetaMAtIndex( + Globals.BackendWrapper.sampleCurrentLayerIndex, thetaM) } EaElements.Label { diff --git a/EasyReflectometryApp/Gui/qmldir b/EasyReflectometryApp/Gui/qmldir index dc412d1f..55e580b0 100644 --- a/EasyReflectometryApp/Gui/qmldir +++ b/EasyReflectometryApp/Gui/qmldir @@ -3,8 +3,8 @@ module Gui ApplicationWindow ApplicationWindow.qml CalculationEngineControl CalculationEngineControl.qml GuideFieldLegend GuideFieldLegend.qml +MagnetizationAngleSlider MagnetizationAngleSlider.qml MagnetizationArrow MagnetizationArrow.qml -MagnetizationCompass MagnetizationCompass.qml MagneticProfileControl MagneticProfileControl.qml PlotControlRefLines PlotControlRefLines.qml SpinAsymmetryChart SpinAsymmetryChart.qml diff --git a/docs/src/tutorials/magnetism.md b/docs/src/tutorials/magnetism.md index 2904d72a..244ae421 100644 --- a/docs/src/tutorials/magnetism.md +++ b/docs/src/tutorials/magnetism.md @@ -33,17 +33,21 @@ example `Magnetism: Multi-layer`, and shows one row per layer of that assembly. scattering. This is the value to start from for a simple saturated film. ``` -### The moment compass +### The moment angle slider -Selecting a magnetic row shows a compass below the table: the same arrow the -[Structure tab](#moment-arrows-on-the-structure-tab) draws, with the guide field **H** -fixed pointing right and the `θM` values of the four cardinal directions on the rim, so -the convention is visible instead of remembered. Its tooltip gives the angle both ways - -`φ` from **H**, and the `θM` the table edits. +Selecting a magnetic row shows a slider below the table, spanning the whole `0-360°` +range of `θM`, with the guide field reference **H →** and the resulting arrow beside it - +the same arrow the [Structure tab](#moment-arrows-on-the-structure-tab) draws. Its +tooltip gives the angle both ways: `φ` from **H**, and the `θM` the table edits. -Dragging inside the circle sets `θM`, snapped to 5°; the text field remains the precise -input. The compass is read-only - and says so in its tooltip - while a fit is running, or -when `θM` follows a constraint, because then the parameter is not the user's to set. +Dragging the slider sets `θM`, snapped to 5°; the text field remains the precise input. +The slider is read-only - and says so in its tooltip - while a fit is running, or when +`θM` follows a constraint, because then the parameter is not the user's to set. + +The slider edits `θM` itself, exactly as the table column does. The arrow beside it shows +`φ`, the direction the moment physically points, so with a **negative `ρM`** the arrow +points opposite the angle on the slider - the moment is reversed while the parameter +stays where it was put. ### Switching the calculation engine diff --git a/tests/test_logic_layers.py b/tests/test_logic_layers.py index 9321e53d..9da99c75 100644 --- a/tests/test_logic_layers.py +++ b/tests/test_logic_layers.py @@ -222,35 +222,15 @@ def test_magnetism_rows_report_the_drawn_direction(): assert (plain['phi'], plain['editable']) == ('', '') -def test_setting_phi_writes_theta_m_through_the_guide_field_convention(): - logic, magnetism = _magnetism_logic(theta_m=40.0) +def test_a_constrained_theta_m_is_reported_as_not_editable(): + logic, _ = _magnetism_logic(theta_m=40.0, independent=False) - assert logic.set_phi_at_index(1, 0.0) is True - - assert magnetism.theta_m.value == 270.0 # phi = 0 is along the guide field - - -def test_setting_phi_on_a_negative_moment_flips_the_parameter_back(): - logic, magnetism = _magnetism_logic(rho_m=-3.0, theta_m=40.0) - - logic.set_phi_at_index(1, 0.0) - - # The moment points along H, so the parameter points the opposite way. - assert magnetism.theta_m.value == 90.0 - assert logic.magnetism[1]['phi'] == '0.0' - - -def test_a_constrained_theta_m_refuses_the_drag(): - logic, magnetism = _magnetism_logic(theta_m=40.0, independent=False) - - assert logic.set_phi_at_index(1, 0.0) is False - - assert magnetism.theta_m.value == 40.0 + # The slider reads this to go read-only rather than to write a refused value. assert logic.magnetism[1]['editable'] == 'False' -def test_setting_phi_on_a_non_magnetic_layer_is_a_no_op(): - logic, _ = _magnetism_logic() +def test_a_negative_moment_points_the_arrow_the_other_way(): + logic, _ = _magnetism_logic(rho_m=-3.0, theta_m=270.0) - assert logic.set_phi_at_index(0, 90.0) is False - assert logic.set_phi_at_index(7, 90.0) is False + # theta_m is along the guide field, so the moment itself points against it. + assert logic.magnetism[1]['phi'] == '180.0' diff --git a/tests/test_qml_magnetization_compass.py b/tests/test_qml_magnetization_compass.py deleted file mode 100644 index 9c93fb3e..00000000 --- a/tests/test_qml_magnetization_compass.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Source-level assertions on the moment compass in the Magnetism group -(spin-direction design A5/A6). No QML engine is instantiated; the angle -convention itself is pinned in the library and in `test_logic_layers.py`. -""" - -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -GUI = ROOT / 'EasyReflectometryApp' / 'Gui' -COMPASS = GUI / 'MagnetizationCompass.qml' -GROUP = GUI / 'Pages' / 'Sample' / 'Sidebar' / 'Basic' / 'Groups' / 'Magnetism.qml' - - -def test_the_compass_reuses_the_shared_arrow(): - compass_qml = COMPASS.read_text(encoding='utf-8') - - assert 'MagnetizationArrow {' in compass_qml - assert 'phi: root.phi' in compass_qml - assert 'import QtQuick.Shapes' not in compass_qml - - -def test_the_compass_follows_the_selection_the_table_already_writes(): - group_qml = GROUP.read_text(encoding='utf-8') - - assert 'Globals.BackendWrapper.sampleLayersMagnetism[Globals.BackendWrapper.sampleCurrentLayerIndex]' in group_qml - assert 'Gui.MagnetizationCompass {' in group_qml - # Gone entirely - not just greyed out - for a layer with no magnetism. - assert 'visible: magnetismGroup.currentRow !== null && magnetismGroup.currentRow.magnetic === "True"' in group_qml - assert 'height: visible ? implicitHeight : 0' in group_qml - - -def test_the_rim_teaches_the_theta_m_convention(): - compass_qml = COMPASS.read_text(encoding='utf-8') - - # 270 at the guide field, growing clockwise on screen. - assert "{theta: '270', dx: 1, dy: 0}" in compass_qml - assert "{theta: '0', dx: 0, dy: -1}" in compass_qml - assert "{theta: '90', dx: -1, dy: 0}" in compass_qml - assert "{theta: '180', dx: 0, dy: 1}" in compass_qml - assert 'text: "H"' in compass_qml - - -def test_dragging_asks_for_a_snapped_phi_and_never_computes_theta_m(): - compass_qml = COMPASS.read_text(encoding='utf-8') - - assert 'signal phiRequested(real phi)' in compass_qml - assert 'property int snapDegrees: 5' in compass_qml - assert 'Math.round(degrees / snapDegrees) * snapDegrees' in compass_qml - # Converting a direction back into the parameter is the backend's job. - assert '270' not in compass_qml.split('function requestFrom', 1)[1] - - -def test_the_group_writes_through_the_backend_and_refuses_when_it_must_not(): - group_qml = GROUP.read_text(encoding='utf-8') - - assert 'sampleSetLayerPhiAtIndex' in group_qml - assert 'magnetismGroup.currentRow.editable === "True"' in group_qml - assert '!Globals.BackendWrapper.analysisFittingRunning' in group_qml - - -def test_the_backend_contract_is_mirrored_in_the_wrapper_and_the_mock(): - wrapper = (GUI / 'Globals' / 'BackendWrapper.qml').read_text(encoding='utf-8') - mock = (ROOT / 'EasyReflectometryApp' / 'Backends' / 'Mock' / 'Sample.qml').read_text(encoding='utf-8') - - assert 'sampleSetLayerPhiAtIndex' in wrapper - assert 'function setLayerPhiAtIndex(index, value)' in mock - assert "'phi': '130.0'" in mock - assert "'editable': 'True'" in mock diff --git a/tests/test_qml_magnetization_slider.py b/tests/test_qml_magnetization_slider.py new file mode 100644 index 00000000..3ff20854 --- /dev/null +++ b/tests/test_qml_magnetization_slider.py @@ -0,0 +1,90 @@ +"""Source-level assertions on the moment angle slider in the Magnetism group +(spin-direction design A5/A6). No QML engine is instantiated; the angle +convention itself is pinned in the library and in `test_logic_layers.py`. +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GUI = ROOT / 'EasyReflectometryApp' / 'Gui' +SLIDER = GUI / 'MagnetizationAngleSlider.qml' +GROUP = GUI / 'Pages' / 'Sample' / 'Sidebar' / 'Basic' / 'Groups' / 'Magnetism.qml' + + +def test_the_slider_spans_the_whole_theta_m_range(): + slider_qml = SLIDER.read_text(encoding='utf-8') + + assert 'from: 0' in slider_qml + assert 'to: 360' in slider_qml + # The dial snapped the drag to 5 deg; stepSize alone would only snap the + # keyboard and the wheel. + assert 'property int snapDegrees: 5' in slider_qml + assert 'stepSize: root.snapDegrees' in slider_qml + assert 'snapMode: Slider.SnapAlways' in slider_qml + + +def test_the_slider_reuses_the_shared_arrow_and_guide_field_reference(): + slider_qml = SLIDER.read_text(encoding='utf-8') + + assert 'MagnetizationArrow {' in slider_qml + assert 'phi: root.phi' in slider_qml + # An arrow is never on screen without the reference it is measured from. + assert 'GuideFieldLegend {' in slider_qml + assert 'import QtQuick.Shapes' not in slider_qml + + +def test_the_slider_edits_theta_m_and_leaves_phi_to_the_backend(): + slider_qml = SLIDER.read_text(encoding='utf-8') + + assert 'signal thetaMRequested(real thetaM)' in slider_qml + assert 'onMoved: root.thetaMRequested(value)' in slider_qml + # phi is drawn, never written: deriving it here would put the guide field + # convention in QML, where it is already wrong once. + assert 'phiRequested' not in slider_qml + assert '270' not in slider_qml.split('EaElements.Slider {', 1)[1] + + +def test_the_backend_stays_authoritative_after_a_drag(): + slider_qml = SLIDER.read_text(encoding='utf-8') + + # Dragging assigns `value` imperatively, so the model value is applied by a + # Binding that stands aside while the handle is held: a refused write must + # move the handle back rather than leave it where the drag left it. + assert 'Binding {' in slider_qml + assert 'property: "value"' in slider_qml + assert 'value: root.thetaM' in slider_qml + assert 'when: !slider.pressed' in slider_qml + + +def test_the_slider_follows_the_selection_the_table_already_writes(): + group_qml = GROUP.read_text(encoding='utf-8') + + assert 'Globals.BackendWrapper.sampleLayersMagnetism[Globals.BackendWrapper.sampleCurrentLayerIndex]' in group_qml + assert 'Gui.MagnetizationAngleSlider {' in group_qml + # Gone entirely - not just greyed out - for a layer with no magnetism. + assert 'visible: magnetismGroup.currentRow !== null && magnetismGroup.currentRow.magnetic === "True"' in group_qml + assert 'height: visible ? implicitHeight : 0' in group_qml + + +def test_the_group_writes_through_the_backend_and_refuses_when_it_must_not(): + group_qml = GROUP.read_text(encoding='utf-8') + + # The same call the theta_m column makes: one write path for one parameter. + assert 'sampleSetLayerThetaMAtIndex' in group_qml + assert 'magnetismGroup.currentRow.editable === "True"' in group_qml + assert '!Globals.BackendWrapper.analysisFittingRunning' in group_qml + + +def test_the_backend_contract_is_mirrored_in_the_wrapper_and_the_mock(): + wrapper = (GUI / 'Globals' / 'BackendWrapper.qml').read_text(encoding='utf-8') + mock = (ROOT / 'EasyReflectometryApp' / 'Backends' / 'Mock' / 'Sample.qml').read_text(encoding='utf-8') + + assert 'sampleSetLayerThetaMAtIndex' in wrapper + assert 'function setLayerThetaMAtIndex(index, value)' in mock + assert "'phi': '130.0'" in mock + assert "'editable': 'True'" in mock + + +def test_the_dial_is_gone(): + assert not (GUI / 'MagnetizationCompass.qml').exists() + assert 'MagnetizationCompass' not in (GUI / 'qmldir').read_text(encoding='utf-8')