diff --git a/.github/workflows/pypi-test.yml b/.github/workflows/pypi-test.yml index 8bcb4b5e..f0c8f88a 100644 --- a/.github/workflows/pypi-test.yml +++ b/.github/workflows/pypi-test.yml @@ -24,6 +24,8 @@ env: jobs: # Job 1: Test installation from PyPI on multiple OS pypi-package-tests: + # Only run the package tests on the master branch + if: github.ref == 'refs/heads/master' strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] @@ -45,18 +47,10 @@ jobs: - name: Init pixi project run: pixi init easyreflectometry - - name: Set the minimum system requirements - working-directory: easyreflectometry - run: pixi project system-requirements add macos 14.0 - - name: Add Python 3.13 from Conda working-directory: easyreflectometry run: pixi add "python=3.13" - - name: Add other Conda dependencies - working-directory: easyreflectometry - run: pixi add gsl - - name: Add easyreflectometry (with dev dependencies) from PyPI working-directory: easyreflectometry run: pixi add --pypi "easyreflectometry[dev]" diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index ea40ee70..f8bfa049 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -13,17 +13,6 @@ name: CI using pip on: [push, pull_request] jobs: - Code_Consistency: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: chartboost/ruff-action@v1 - - name: Suggestion to fix issues - if: ${{ failure() }} - run: | - echo "::notice::In project root run 'python.exe -m ruff . --fix' and commit changes to fix issues." - exit 1 - Code_Testing: strategy: max-parallel: 4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 530c154f..51de8d5c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -174,6 +174,8 @@ jobs: # Job 3: Test the package package-test: needs: source-test # depend on previous job + # Only run the package tests on the master branch + if: github.ref == 'refs/heads/master' strategy: fail-fast: false @@ -217,9 +219,6 @@ jobs: echo "Adding Python $py_ver" pixi add "python=$py_ver" - echo "Setting macOS 14.0 as minimum required" - pixi project system-requirements add macos 14.0 - echo "Looking for wheel in ../dist/py$py_ver/" ls -l "../dist/py$py_ver/" diff --git a/CHANGELOG.md b/CHANGELOG.md index 938f27b5..1d9e5c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,176 @@ +# Unreleased + +All four polarization channels (pp, pm, mp, mm) are now available from +the refl1d calculator. Previously only the non-spin-flip pp channel was +returned. + +- New `LayerMagnetism` sample element. `Layer` takes an optional + `magnetism` with fittable, serialized `Parameter`s `rho_m` (magnetic + SLD) and `theta_m` (in-plane moment angle). Adding a magnetic layer + turns on `include_magnetism` on the calculator, or raises + `NotImplementedError` if the backend cannot do magnetism. Removing the + last magnetic layer turns it off again. `Model.has_magnetism`, + `CalculatorBase.supports_magnetism` and + `Project.calculator_supports_magnetism` report the current state. +- New `PolarizedDataSet` groups per-channel `DataSet1D` objects (one + file per channel; NSF experiments use 'pp'/'mm' only, spin-flip + channels are optional) into one experiment that shares a single model. + `Project.load_polarized_experiment(paths)` loads from an explicit + channel-to-file mapping. + `Project.suggest_polarized_channel_assignment(paths)` fills that + mapping from the ORSO header polarization (`pp`/`mm`/`pm`/`mp` only). + Partially analysed observables such as `po`/`mo` (channel sums) and + `op`/`om`/`unpolarized` are left for the user. For plain text files + the mapping comes from filename tokens (`_uu`/`_up`/`_pp` → pp, + `_dd`/`_down`/`_mm` → mm, `_ud`/`_pm` → pm, `_du`/`_mp` → mp). +- Experiment and model accessors are channel-aware. + `Project.experimental_data_for_model_at_index(index, channel=...)` + returns the `DataSet1D` of one spin channel. `channel=None` (the + default) still returns the stored experiment. + `Project.model_data_for_model_at_index(index, q_range, channel=...)` + calculates one spin cross-section. + `Project.experiment_is_polarized_at_index(index)` and + `Project.experiment_channels_at_index(index)` report the polarization + state. A channel that was not measured raises `KeyError`. An unknown + channel, or any channel on an unpolarized experiment, raises + `ValueError`. +- Summary/report figures now plot one measured series per spin channel + of a polarized experiment, each in its channel colour, plus the + matching calculated cross-section. Channels that cannot be calculated + (for example spin-flip on a non-magnetic model) are shown without a + calculated overlay. Previously a polarized experiment made the report + figures fail on `PolarizedDataSet.x`. +- The summary experiments table lists one row per spin channel of a + polarized experiment, named ` ()`. It previously + raised + `AttributeError: 'PolarizedDataSet' object has no attribute 'x'` and + crashed anything that read the summary while a polarized experiment + was loaded. +- New `Project.calculators_supporting_magnetism` lists the available + calculators that can model magnetic samples, without switching the + active one. `Project.models_have_magnetism` reports whether any model + has a magnetic layer. Use these to pick a suitable engine, or to + refuse one that cannot carry the sample's magnetism, instead of + hitting an error inside the binding. +- New `Project.magnetic_sld_data_for_model_at_index(index)` returns the + depth profiles of a magnetic model as `DataSet1D`s keyed `'sld'`, + `'rho_m'`, `'theta_m'`, `'spin_up'` and `'spin_down'`. The last two + are the potentials each spin state sees, rho +/- rho_m\*cos(theta_m - + A). The guide-field angle A is the new module constant + `GUIDE_FIELD_ANGLE` (270 degrees, refl1d's default and the only value + the library can currently model). A non-magnetic model raises + `ValueError`. `Project.model_has_magnetism_at_index(index)` reports + whether the model is magnetic. +- The magnetic depth profile is now built by smoothing the two in-plane + components of the moment and converting back, rather than smoothing + magnitude and angle separately as refl1d does channel by channel. At + an interface where moments differ by a few degrees across 0/360, the + smoothed _angle_ used to take the long way around the circle, pass + through the guide-field direction, and report the full moment as + longitudinal. That produced a spurious spin-up/spin-down splitting + exactly at the interface (a 2-degree difference gave the full 2*rho_m + splitting; it is now the correct ~0.02*rho_m). Collinear samples are + unaffected. The reported `theta_m` profile is restricted to depths + that carry a moment (the angle of a zero-length vector is arbitrary) + and is made continuous within each magnetic region. A profile turning + from 359 to 1 degree is a 2 degree turn; the wrapped values would plot + as a full sweep. If the installed refl1d does not expose the microslab + data the component-safe profile needs, the calculator now raises + `NotImplementedError` instead of falling back to the angle-smoothed + profile. +- New `Project.spin_asymmetry_for_experiment_at_index(index)` returns + the measured spin asymmetry (R++ - R--)/(R++ + R--) of a polarized + experiment, the matching model curve when the model is magnetic, and + the number of points dropped. `ye` holds the SA **variance**, as + everywhere else in the library. Channels measured on different q grids + are interpolated onto the pp grid (values with the linear weights, + variances with their squares) only inside the q range both channels + cover. Outside that range `np.interp` would clamp to the edge value. + Dropped points are reported as `out_of_overlap_points`. Points where + R++ + R-- is not above `SPIN_ASYMMETRY_SIGNIFICANCE` (3) times its own + uncertainty are also dropped. A second, uncertainty-independent guard + drops points whose denominator is non-positive or smaller than + `SPIN_ASYMMETRY_CANCELLATION_FRACTION` (1e-3) of |R++| + |R--|. + Without it, a file with no uncertainties (two columns, or a malformed + uncertainty array) had no guard, and background-subtracted data could + put values of +/-1e3 on the axis. Points with a non-finite + reflectivity or a negative/non-finite variance are dropped rather than + treated as having no uncertainty. Dropped points are reported by + reason (`low_significance_points`, `small_denominator_points`, + `invalid_points`). +- Both channels of a spin asymmetry are validated before use. Empty, + length-mismatched, non-finite or duplicated q grids are rejected, and + `experiment_supports_spin_asymmetry_at_index` reports False for them. + A descending grid is sorted before pairing; `np.interp` silently + returns nonsense for one. + `Project.experiment_supports_spin_asymmetry_at_index(index)` reports + whether both non-spin-flip channels were measured. +- New `calculate_channel(q, model, channel)` on the wrapper (and + `reflectivity_profile_channel` on the calculator, + `fit_func_for_channel` on `CalculatorFactory`) evaluates one explicit + spin channel without touching the global `polarization_channel` state. +- New `MultiFitter.for_experiments(experiments)` builds a fitter with + one fit function per dataset (one per measured spin channel for a + polarized experiment, one for an ordinary one) across any number of + experiments and models, and returns without running the fit. + `fit_datasets` and `fit_channels` give the flat dataset list in + fit-function order, so an application can prepare the data arrays and + drive `easy_science_multi_fitter.fit(...)` from a worker thread. +- New `MultiFitter.record_fit_results(results)` adopts results from such + a caller-driven fit, so `chi2` and `reduced_chi` describe it instead + of reporting that no fit was performed. The classical metrics need the + original data arrays and stay None. +- `rho_m` now takes part in the project's default-limit policy. It is + created with `default_limits_pending`, and + `Project._sync_parameter_states` gives it the shared SLD window (-1 + to 10) unless an explicit `Parameter` with its own bounds was passed. + `theta_m` keeps its explicit 0-360 bounds. Previously both stayed + unbounded. +- New `MultiFitter.fit_polarized(data)` fits all measured channels of a + `PolarizedDataSet` simultaneously against the shared model: one fit + function per channel, common structural parameters, magnetic + parameters constrained by all channels at once. Returns per-channel + `FitResults`. +- The refl1d wrapper now caches the four polarized cross-sections per + model state and (q, dq) grid. They come from a single kernel + evaluation, so a simultaneous N-channel fit costs about one evaluation + per iteration instead of N. + +- New `polarized_reflectivity_profiles(x_array, model_id)` on the + calculator (and on `CalculatorFactory`) returns the reflectivity of + all four spin channels in one calculation as a dictionary keyed + `'pp'`, `'pm'`, `'mp'`, `'mm'` (in that order). Requires + `include_magnetism = True`. +- New `polarization_channel` property (accepts + `'pp'`/`'pm'`/`'mp'`/`'mm'` or the new `PolarizationChannel` enum) + selects which channel `reflectity_profile` (and therefore fitting) + returns, so fits can target spin-flip or mm data. Default `'pp'`; + disabling magnetism resets it to `'pp'`. The channel belongs to the + currently active calculator instance, not to a model or dataset. It + affects every subsequent calculation with that calculator. + `interface.switch(...)` constructs a fresh calculator and resets both + this and `include_magnetism`. +- New `magnetic_sld_profile(model_id)` on the calculator (and on + `CalculatorFactory`) returns the nuclear and magnetic scattering + length density profiles as a tuple `z`, `sld(z)`, `rhoM(z)` (magnetic + SLD) and `thetaM(z)` (magnetic angle). Requires + `include_magnetism = True`; refl1d only. +- Magnetic calculations now always build all four refl1d cross-sections, + so they may take somewhat longer than before. pp results are + unchanged. +- Bug fix: `include_magnetism = True` on a refnx-backed calculator now + raises `NotImplementedError`. Previously it was silently accepted (the + guard sat on a property the calculator never called) even though refnx + magnetism is not supported. +- Bug fix (pre-existing): disabling magnetism after layers were created + with it enabled used to leave refl1d `Magnetism` objects on the slabs, + and a later unpolarized calculation raised `AttributeError` inside + refl1d. Disabling magnetism now strips the magnetic state from + existing layers. Magnetic parameters (`rhoM`/`thetaM`) are kept in a + per-layer store inside the wrapper, so they survive a + disable/re-enable cycle and are re-attached when magnetism is enabled + again. `update_layer` also accepts the magnetism keys one at a time. + # Version 1.7.0 (1 Aug 2026) Restored the measured per-point resolution on data load (issue #368). diff --git a/docs/docs/api-reference/calculators.md b/docs/docs/api-reference/calculators.md new file mode 100644 index 00000000..7564c19a --- /dev/null +++ b/docs/docs/api-reference/calculators.md @@ -0,0 +1,29 @@ +# Calculators + +The calculator translates an EasyReflectometry model into a backend +engine (refl1d or refnx) and computes reflectivity. + +## Polarized reflectivity + +With the refl1d calculator and `include_magnetism` enabled, all four +spin channels are available: + +- `polarized_reflectivity_profiles(x_array, model_id)` returns the + reflectivity of all four channels as a dictionary keyed `'pp'`, + `'pm'`, `'mp'`, `'mm'` (in that order). +- `polarization_channel` selects which channel `reflectity_profile` — + and hence fitting — uses (default `'pp'`). +- `magnetic_sld_profile(model_id)` returns the nuclear and magnetic + scattering length density profiles as a tuple `z`, `sld(z)`, `rhoM(z)` + (magnetic SLD) and `thetaM(z)` (magnetic angle). + +Note that `polarization_channel` is state of the currently active +calculator instance, not of a model or dataset: it affects every +subsequent calculation using that calculator, and switching calculators +via the factory constructs a fresh instance, which resets the channel +(along with `include_magnetism`). Calculators without magnetism support +(refnx) raise `NotImplementedError` when magnetism is enabled. + +::: easyreflectometry.calculators.polarization + +::: easyreflectometry.calculators.calculator_base diff --git a/docs/docs/tutorials/advancedfitting/bayesian_bumps.ipynb b/docs/docs/tutorials/advancedfitting/bayesian_bumps.ipynb index dcaaa025..bf136533 100644 --- a/docs/docs/tutorials/advancedfitting/bayesian_bumps.ipynb +++ b/docs/docs/tutorials/advancedfitting/bayesian_bumps.ipynb @@ -217,12 +217,18 @@ "# We deliberately start with a *very* short run. It finishes in seconds but is\n", "# far too short to trust — which is exactly the situation the \"extend the chain\"\n", "# section below exists to fix. In production you would ask for 20 k+ samples.\n", + "#\n", + "# ``thin=1`` here (rather than the ``10`` used later in ``extend()``) works\n", + "# around a BUMPS DREAM bug: its outlier-chain replacement indexes the thinned\n", + "# sample buffer using the un-thinned generation counter, which overruns the\n", + "# buffer whenever outlier removal fires before enough thinned generations\n", + "# have accumulated — reliably the case for a run this short with thin > 1.\n", "\n", "posterior_dict = fitter.mcmc_sample(\n", " data,\n", " samples=500, # Deliberately too short — extended later in this notebook\n", " burn=100,\n", - " thin=10,\n", + " thin=1,\n", ")\n", "\n", "print('DREAM sampling complete.')\n", diff --git a/docs/docs/tutorials/advancedfitting/polarized_fitting.ipynb b/docs/docs/tutorials/advancedfitting/polarized_fitting.ipynb new file mode 100644 index 00000000..0200940c --- /dev/null +++ b/docs/docs/tutorials/advancedfitting/polarized_fitting.ipynb @@ -0,0 +1,528 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Polarized Neutron Reflectometry: Channels, Depth Profiles & Simultaneous Fitting\n", + "\n", + "`magnetism.ipynb` (in the *Simulation* section) introduces magnetic layers and\n", + "how to select a single polarization channel. This tutorial picks up from\n", + "there and focuses on what is new for **polarization analysis**:\n", + "\n", + "1. Computing all four spin cross-sections (`pp`, `pm`, `mp`, `mm`) in\n", + " a single call.\n", + "2. Reading the spin-resolved depth profile. The potential each neutron\n", + " spin state actually sees.\n", + "3. Loading a polarized experiment from per-channel data files and forming the\n", + " **spin asymmetry**, with correct error propagation.\n", + "4. **Fitting multiple polarization channels simultaneously** against one\n", + " shared model with `MultiFitter.fit_polarized()`.\n", + " First recovering the moment's magnitude from the two\n", + " non-spin-flip channels, then recovering the full magnetization vector\n", + " (magnitude *and* direction) from all four channels.\n", + "\n", + "Only the `refl1d` calculator supports magnetism; `refnx` does\n", + "not. All magnetism handling: enabling it on the calculator, computing\n", + "channels, fitting, requires `refl1d`.\n", + "\n", + "The reflectometry convention used throughout: with the default guide field,\n", + "a moment at ``theta_m = 270`` degrees is aligned with it (no spin-flip\n", + "scattering); ``theta_m = 90`` is anti-aligned. A **canted** moment away from\n", + "270/90 produces spin-flip scattering (`pm`, `mp`) alongside the\n", + "non-spin-flip channels (`pp`, `mm`). This is why the sample below uses\n", + "``theta_m = 45`` degrees rather than a value aligned with the guide field:\n", + "it is the only choice that makes all four channels visually distinct." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import tempfile\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from easyreflectometry.calculators import CalculatorFactory\n", + "from easyreflectometry.data import DataSet1D\n", + "from easyreflectometry.data import PolarizedDataSet\n", + "from easyreflectometry.fitting import MultiFitter\n", + "from easyreflectometry.model import Model\n", + "from easyreflectometry.model import ModelCollection\n", + "from easyreflectometry.model import PercentageFwhm\n", + "from easyreflectometry.project import Project\n", + "from easyreflectometry.sample import Layer\n", + "from easyreflectometry.sample import LayerMagnetism\n", + "from easyreflectometry.sample import Material\n", + "from easyreflectometry.sample import Multilayer\n", + "from easyreflectometry.sample import Sample\n", + "\n", + "print('All libraries imported successfully.')" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": { + "lines_to_next_cell": 2 + }, + "source": [ + "## 1. Build a magnetic sample\n", + "\n", + "A single magnetic layer between two non-magnetic media: a thin Fe film\n", + "(nuclear SLD 4, in units of $10^{-6}$ Å$^{-2}$) with an in-plane magnetic\n", + "moment, on a Si substrate below a vacuum superphase.\n", + "\n", + "Magnetism is defined with `Layer.magnetism = LayerMagnetism(rho_m=..., theta_m=...)`\n", + "(or passed directly to `Layer(..., magnetism=...)`, as below). This\n", + "enables magnetism on the calculator once the layer has an interface.\n", + "There is no separate \"turn magnetism on\" step required.\n", + "\n", + "`LayerMagnetism` exposes two standard, fittable `Parameter`s:\n", + "\n", + "| Parameter | Meaning | Unit | Default |\n", + "|-----------|-----------------------------------------|-----------------------------|---------|\n", + "| `rho_m` | Magnetic scattering length density | $10^{-6}$ Å$^{-2}$ | 0.0 |\n", + "| `theta_m` | In-plane moment angle vs. the beam | degree | 270.0 |\n", + "\n", + "We wrap sample construction in a function so the same recipe can be reused\n", + "below to build a \"truth\" model and, later, independent \"fit starting point\"\n", + "models." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "def build_magnetic_model(rho_m: float, theta_m: float, name: str) -> tuple[Model, Layer]:\n", + " \"\"\"Build a Vacuum / Fe(magnetic) / Si model and return it with the magnetic layer.\n", + "\n", + " :param rho_m: Magnetic SLD of the Fe film, in 1e-6/angstrom^2.\n", + " :param theta_m: In-plane moment angle of the Fe film, in degrees.\n", + " :param name: Name for the model.\n", + " :return: The model (interface already switched to refl1d) and the Fe layer,\n", + " so its ``.magnetism`` parameters can be reached directly for fitting.\n", + " \"\"\"\n", + " vacuum = Material(sld=0, isld=0, name='Vacuum')\n", + " iron = Material(sld=4.0, isld=0, name='Fe')\n", + " silicon = Material(sld=2.047, isld=0, name='Si')\n", + "\n", + " superphase = Layer(material=vacuum, thickness=0, roughness=0, name='Vacuum Superphase')\n", + " film = Layer(\n", + " material=iron,\n", + " thickness=100,\n", + " roughness=0,\n", + " magnetism=LayerMagnetism(rho_m=rho_m, theta_m=theta_m, name='Fe film moment'),\n", + " name='Fe Film',\n", + " )\n", + " subphase = Layer(material=silicon, thickness=0, roughness=0, name='Si Subphase')\n", + "\n", + " sample = Sample(Multilayer(superphase), Multilayer(film), Multilayer(subphase), name='Vacuum / Fe(magnetic) / Si')\n", + " model = Model(sample=sample, scale=1, background=0, name=name)\n", + " model.resolution_function = PercentageFwhm(0) # 0% resolution keeps this simulation clean\n", + "\n", + " interface = CalculatorFactory()\n", + " interface.switch('refl1d') # the only calculator that supports magnetism\n", + " model.interface = interface\n", + "\n", + " return model, film\n", + "\n", + "\n", + "RHO_M_TRUE = 2.5 # 1e-6 / angstrom^2\n", + "THETA_M_TRUE = 45.0 # degrees -- canted, so all four channels differ\n", + "\n", + "truth_model, truth_film = build_magnetic_model(RHO_M_TRUE, THETA_M_TRUE, name='Truth model')\n", + "\n", + "print(f'Fe film magnetism: rho_m = {truth_film.magnetism.rho_m.value}, theta_m = {truth_film.magnetism.theta_m.value}')" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## 2. All four polarization channels\n", + "\n", + "`model.interface.polarized_reflectivity_profiles(q, model_name)` returns a\n", + "dict with all four spin cross-sections at once:\n", + "\n", + "- `'pp'` — non-spin-flip, up-up\n", + "- `'mm'` — non-spin-flip, down-down\n", + "- `'pm'` — spin-flip, up-down\n", + "- `'mp'` — spin-flip, down-up\n", + "\n", + "Internally this is a single `refl1d` kernel evaluation shared by all four\n", + "channels (and cached per model state), so this costs about the same as\n", + "computing one channel. For a single explicit channel without touching any\n", + "calculator state, use `reflectivity_profile_channel(q, model_name, channel)`\n", + "instead. Both are stateless, unlike setting `interface.polarization_channel`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "Q_PLOT = np.linspace(0.001, 0.3, 500)\n", + "\n", + "channels_truth = truth_model.interface.polarized_reflectivity_profiles(Q_PLOT, truth_model.unique_name)\n", + "\n", + "plt.figure(figsize=(8, 5))\n", + "plt.semilogy(Q_PLOT, channels_truth['pp'], '-k', label='pp (non-spin-flip)', linewidth=2)\n", + "plt.semilogy(Q_PLOT, channels_truth['mm'], '-r', label='mm (non-spin-flip)', linewidth=2)\n", + "plt.semilogy(Q_PLOT, channels_truth['pm'], ':k', label='pm (spin-flip)', linewidth=2)\n", + "plt.semilogy(Q_PLOT, channels_truth['mp'], ':r', label='mp (spin-flip)', linewidth=2)\n", + "plt.xlabel('Q / Å⁻¹')\n", + "plt.ylabel('Reflectivity')\n", + "plt.title(f'Four polarization channels (rho_m={RHO_M_TRUE}, theta_m={THETA_M_TRUE}°)')\n", + "plt.legend()\n", + "plt.grid(True, alpha=0.3)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## 3. The spin-resolved depth profile\n", + "\n", + "`Project.magnetic_sld_data_for_model_at_index()` returns the nuclear SLD\n", + "profile alongside the two potentials a neutron in each spin state actually\n", + "experiences: `spin_up = sld + rho_m * cos(theta_m - guide_field_angle)` and\n", + "`spin_down = sld - rho_m * cos(...)`. With a canted moment (not aligned with\n", + "the guide field) the split between the two curves is reduced by that cosine\n", + "factor rather than being the full `rho_m`.\n", + "\n", + "This wraps the model in a `Project`, the same object a GUI application uses\n", + "to manage models, experiments and fitting." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "project = Project()\n", + "project.calculator = 'refl1d'\n", + "project.models = ModelCollection(truth_model)\n", + "\n", + "profiles = project.magnetic_sld_data_for_model_at_index(0)\n", + "\n", + "plt.figure(figsize=(8, 5))\n", + "plt.plot(profiles['sld'].x, profiles['sld'].y, '-k', label='Nuclear SLD', linewidth=2)\n", + "plt.plot(profiles['spin_up'].x, profiles['spin_up'].y, '-b', label='Spin-up potential', linewidth=2)\n", + "plt.plot(profiles['spin_down'].x, profiles['spin_down'].y, '-r', label='Spin-down potential', linewidth=2)\n", + "plt.xlabel('z / Å')\n", + "plt.ylabel('SLD / 10⁻⁶ Å⁻²')\n", + "plt.title('Nuclear SLD and the two spin-dependent potentials')\n", + "plt.legend()\n", + "plt.grid(True, alpha=0.3)\n", + "plt.show()\n", + "\n", + "# The moment magnitude and direction are also available on their own, restricted\n", + "# to the depths that actually carry a moment (an angle is meaningless at zero SLD).\n", + "print(f'Peak magnetic SLD in the film: {profiles[\"rho_m\"].y.max():.3f} (expected {RHO_M_TRUE})')\n", + "print(f'Moment angle inside the film: {profiles[\"theta_m\"].y.mean():.1f}° (expected {THETA_M_TRUE}°)')" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": { + "lines_to_next_cell": 2 + }, + "source": [ + "## 4. Loading a polarized experiment and forming the spin asymmetry\n", + "\n", + "A polarized measurement is typically serialized as one data file per spin channel\n", + "(e.g. `..._uu.dat` for up-up, `..._dd.dat` for down-down). Here we simulate\n", + "that by writing the truth model's `pp`/`mm` reflectivity, with 1% relative\n", + "noise, to two files, then loading them back exactly as a user would with\n", + "real instrument output.\n", + "\n", + "The **spin asymmetry** $SA = (R^{++} - R^{--}) / (R^{++} + R^{--})$ is a\n", + "common way to look at polarized data directly: it cancels the non-magnetic\n", + "(nuclear) part of the reflectivity and isolates the magnetic signal, with\n", + "`Project.spin_asymmetry_for_experiment_at_index()` handling the variance\n", + "propagation and dropping points where the denominator is too small to be\n", + "meaningful." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "def add_relative_noise(\n", + " reflectivity: np.ndarray, relative_sigma: float, rng: np.random.Generator\n", + ") -> tuple[np.ndarray, np.ndarray]:\n", + " \"\"\"Add reproducible Gaussian noise scaled to a fixed fraction of the signal.\n", + "\n", + " :param reflectivity: Noise-free reflectivity values.\n", + " :param relative_sigma: Standard deviation as a fraction of the signal (e.g. 0.01 for 1%).\n", + " :param rng: Seeded random number generator, for reproducible tutorial output.\n", + " :return: Noisy reflectivity and the per-point standard deviation (not variance).\n", + " \"\"\"\n", + " sigma = relative_sigma * np.abs(reflectivity)\n", + " noisy = rng.normal(loc=reflectivity, scale=sigma)\n", + " return np.clip(noisy, 1e-12, None), sigma\n", + "\n", + "\n", + "Q_DATA = np.linspace(0.01, 0.25, 60) # a more realistic, instrument-like grid\n", + "rng = np.random.default_rng(seed=42) # fixed seed: this tutorial's output is reproducible\n", + "\n", + "channels_data_grid = truth_model.interface.polarized_reflectivity_profiles(Q_DATA, truth_model.unique_name)\n", + "noisy_channels = {\n", + " channel: add_relative_noise(reflectivity, relative_sigma=0.01, rng=rng)\n", + " for channel, reflectivity in channels_data_grid.items()\n", + "}\n", + "\n", + "tmp_dir = Path(tempfile.mkdtemp(prefix='easyreflectometry_polarized_'))\n", + "pp_path = tmp_dir / 'fe_film_uu.txt'\n", + "mm_path = tmp_dir / 'fe_film_dd.txt'\n", + "np.savetxt(pp_path, np.column_stack([Q_DATA, noisy_channels['pp'][0], noisy_channels['pp'][1]]))\n", + "np.savetxt(mm_path, np.column_stack([Q_DATA, noisy_channels['mm'][0], noisy_channels['mm'][1]]))\n", + "\n", + "# The filename suffixes ('_uu', '_dd') are recognised automatically.\n", + "print(project.suggest_polarized_channel_assignment([pp_path, mm_path]))\n", + "\n", + "experiment_index = project.load_polarized_experiment({'pp': pp_path, 'mm': mm_path})\n", + "loaded_channels = project.experiment_channels_at_index(experiment_index)\n", + "print(f'Loaded polarized experiment at index {experiment_index}, channels: {loaded_channels}')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "spin_asymmetry = project.spin_asymmetry_for_experiment_at_index(experiment_index)\n", + "measured, calculated = spin_asymmetry['measured'], spin_asymmetry['calculated']\n", + "\n", + "plt.figure(figsize=(8, 5))\n", + "plt.errorbar(\n", + " measured.x,\n", + " measured.y,\n", + " yerr=np.sqrt(measured.ye),\n", + " fmt='o',\n", + " color='0.3',\n", + " markersize=4,\n", + " alpha=0.6,\n", + " label='Measured (loaded files)',\n", + ")\n", + "plt.plot(calculated.x, calculated.y, '-r', linewidth=2, label='Calculated (truth model)')\n", + "plt.xlabel('Q / Å⁻¹')\n", + "plt.ylabel('Spin asymmetry')\n", + "plt.title('Spin asymmetry: loaded data vs. the model it was generated from')\n", + "plt.legend()\n", + "plt.grid(True, alpha=0.3)\n", + "plt.show()\n", + "\n", + "print(\n", + " f'{measured.x.size} of {Q_DATA.size} points kept '\n", + " f'({spin_asymmetry[\"masked_points\"]} masked: '\n", + " f'{spin_asymmetry[\"low_significance_points\"]} low-significance, '\n", + " f'{spin_asymmetry[\"small_denominator_points\"]} small-denominator).'\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "## 5. Fitting: recovering the moment magnitude from two channels\n", + "\n", + "The most common polarized experiment measures only the two non-spin-flip\n", + "channels (`pp`, `mm`). If the moment's *direction* is already known from\n", + "other means (sample geometry, prior characterization), that alone is enough\n", + "to recover its *magnitude*. This is the standard polarized-fitting case.\n", + "\n", + "`MultiFitter.fit_polarized()` takes a `PolarizedDataSet` and fits every\n", + "channel it contains **simultaneously against one shared model**: any\n", + "structural parameter (thickness, roughness, nuclear SLD, scale, background)\n", + "is constrained jointly by all measured channels, and so is `rho_m`/`theta_m`.\n", + "Internally `refl1d` still evaluates all cross-sections from a single kernel\n", + "call, so fitting N channels together costs about as much as fitting one.\n", + "\n", + "We start from a deliberately wrong `rho_m` guess and fit against the two\n", + "noisy channels loaded above; `theta_m` stays fixed at its (assumed known)\n", + "true value." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "fit_model_2ch, fit_film_2ch = build_magnetic_model(rho_m=1.0, theta_m=THETA_M_TRUE, name='Fit: two channels (rho_m only)')\n", + "\n", + "fit_film_2ch.magnetism.rho_m.fixed = False\n", + "fit_film_2ch.magnetism.rho_m.bounds = (0.0, 5.0)\n", + "fit_film_2ch.magnetism.theta_m.fixed = True # moment direction assumed known\n", + "\n", + "initial_channels_2ch = fit_model_2ch.interface.polarized_reflectivity_profiles(Q_PLOT, fit_model_2ch.unique_name)\n", + "\n", + "fit_data_2ch = PolarizedDataSet(\n", + " name='Fe film (pp, mm)',\n", + " channels={\n", + " 'pp': DataSet1D(name='pp', x=Q_DATA, y=noisy_channels['pp'][0], ye=noisy_channels['pp'][1] ** 2),\n", + " 'mm': DataSet1D(name='mm', x=Q_DATA, y=noisy_channels['mm'][0], ye=noisy_channels['mm'][1] ** 2),\n", + " },\n", + " model=fit_model_2ch, # PolarizedDataSet.model must be the model the fitter is constructed with\n", + ")\n", + "\n", + "fitter_2ch = MultiFitter(fit_model_2ch)\n", + "results_2ch = fitter_2ch.fit_polarized(fit_data_2ch)\n", + "\n", + "print(f'Channels fitted: {list(results_2ch.keys())}, all successful: {all(r.success for r in results_2ch.values())}')\n", + "print(f'rho_m: {fit_film_2ch.magnetism.rho_m.value:.3f} (started at 1.0, true value {RHO_M_TRUE})')\n", + "print(f'Reduced chi^2: {fitter_2ch.reduced_chi:.3f}')\n", + "\n", + "fitted_channels_2ch = fit_model_2ch.interface.polarized_reflectivity_profiles(Q_PLOT, fit_model_2ch.unique_name)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "plt.figure(figsize=(8, 5))\n", + "plt.errorbar(\n", + " Q_DATA,\n", + " noisy_channels['pp'][0],\n", + " yerr=noisy_channels['pp'][1],\n", + " fmt='o',\n", + " color='0.3',\n", + " markersize=4,\n", + " alpha=0.5,\n", + " label='pp (data)',\n", + ")\n", + "plt.errorbar(\n", + " Q_DATA,\n", + " noisy_channels['mm'][0],\n", + " yerr=noisy_channels['mm'][1],\n", + " fmt='s',\n", + " color='0.6',\n", + " markersize=4,\n", + " alpha=0.5,\n", + " label='mm (data)',\n", + ")\n", + "plt.semilogy(Q_PLOT, initial_channels_2ch['pp'], '--k', linewidth=1, alpha=0.6, label='pp (initial guess)')\n", + "plt.semilogy(Q_PLOT, initial_channels_2ch['mm'], '--r', linewidth=1, alpha=0.6, label='mm (initial guess)')\n", + "plt.semilogy(Q_PLOT, fitted_channels_2ch['pp'], '-k', linewidth=2, label='pp (fitted)')\n", + "plt.semilogy(Q_PLOT, fitted_channels_2ch['mm'], '-r', linewidth=2, label='mm (fitted)')\n", + "plt.yscale('log')\n", + "plt.xlabel('Q / Å⁻¹')\n", + "plt.ylabel('Reflectivity')\n", + "plt.title('Two-channel fit: rho_m recovered from pp and mm together')\n", + "plt.legend(fontsize=8)\n", + "plt.grid(True, alpha=0.3)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "## 6. Fitting: recovering the full magnetization vector from four channels\n", + "\n", + "When the spin-flip channels (`pm`, `mp`) are also measured, `fit_polarized`\n", + "can determine the moment's *direction* as well as its magnitude. Both\n", + "`rho_m` and `theta_m` are freed and constrained jointly by all four\n", + "channels. This is the distinguishing capability of full polarization\n", + "analysis over a non-spin-flip-only measurement.\n", + "\n", + "We start from wrong guesses for **both** parameters this time." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "fit_model_4ch, fit_film_4ch = build_magnetic_model(rho_m=1.5, theta_m=60.0, name='Fit: four channels (rho_m and theta_m)')\n", + "\n", + "fit_film_4ch.magnetism.rho_m.fixed = False\n", + "fit_film_4ch.magnetism.rho_m.bounds = (0.0, 5.0)\n", + "fit_film_4ch.magnetism.theta_m.fixed = False\n", + "fit_film_4ch.magnetism.theta_m.bounds = (0.0, 90.0)\n", + "\n", + "fit_data_4ch = PolarizedDataSet(\n", + " name='Fe film (pp, pm, mp, mm)',\n", + " channels={\n", + " channel: DataSet1D(name=channel, x=Q_DATA, y=values[0], ye=values[1] ** 2) for channel, values in noisy_channels.items()\n", + " },\n", + " model=fit_model_4ch,\n", + ")\n", + "\n", + "fitter_4ch = MultiFitter(fit_model_4ch)\n", + "results_4ch = fitter_4ch.fit_polarized(fit_data_4ch)\n", + "\n", + "print(f'Channels fitted: {list(results_4ch.keys())}, all successful: {all(r.success for r in results_4ch.values())}')\n", + "print(f'rho_m: {fit_film_4ch.magnetism.rho_m.value:.3f} (started at 1.5, true value {RHO_M_TRUE})')\n", + "print(f'theta_m: {fit_film_4ch.magnetism.theta_m.value:.1f}° (started at 60.0°, true value {THETA_M_TRUE}°)')\n", + "print(f'Reduced chi^2: {fitter_4ch.reduced_chi:.3f}')" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "See `docs/docs/tutorials/simulation/magnetism.ipynb` for the basics of\n", + "building magnetic samples and selecting a single channel, and\n", + "`tests/test_polarized_fitting.py` for the full, tested API which\n", + "this tutorial is based on." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "easyref", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/docs/tutorials/advancedfitting/polarized_fitting.py b/docs/docs/tutorials/advancedfitting/polarized_fitting.py new file mode 100644 index 00000000..b46d2e57 --- /dev/null +++ b/docs/docs/tutorials/advancedfitting/polarized_fitting.py @@ -0,0 +1,417 @@ +# %% [markdown] +# # Polarized Neutron Reflectometry: Channels, Depth Profiles & Simultaneous Fitting +# +# `magnetism.ipynb` (in the *Simulation* section) introduces magnetic layers and +# how to select a single polarization channel. This tutorial picks up from +# there and focuses on what is new for **polarization analysis**: +# +# 1. Computing all four spin cross-sections (`pp`, `pm`, `mp`, `mm`) in a single, +# stateless call. +# 2. Reading off the spin-resolved depth profile — the potential each neutron +# spin state actually sees. +# 3. Loading a polarized experiment from per-channel data files and forming the +# **spin asymmetry**, with proper error propagation. +# 4. **Fitting multiple polarization channels simultaneously** against one +# shared model with `MultiFitter.fit_polarized()` — the headline new +# capability — first recovering just the moment's magnitude from the two +# non-spin-flip channels, then recovering the full magnetization vector +# (magnitude *and* direction) from all four channels. +# +# Only the `refl1d` calculator supports magnetism; `refnx` and `bornagain` do +# not. All magnetism handling — enabling it on the calculator, computing +# channels, fitting — goes through `refl1d`. +# +# The reflectometry convention used throughout: with the default guide field, +# a moment at ``theta_m = 270`` degrees is aligned with it (no spin-flip +# scattering); ``theta_m = 90`` is anti-aligned. A **canted** moment away from +# 270/90 produces spin-flip scattering (`pm`, `mp`) alongside the +# non-spin-flip channels (`pp`, `mm`) — which is why the sample below uses +# ``theta_m = 45`` degrees rather than a value aligned with the guide field: +# it is the only choice that makes all four channels visually distinct. + +# %% +import tempfile +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np + +from easyreflectometry.calculators import CalculatorFactory +from easyreflectometry.data import DataSet1D +from easyreflectometry.data import PolarizedDataSet +from easyreflectometry.fitting import MultiFitter +from easyreflectometry.model import Model +from easyreflectometry.model import ModelCollection +from easyreflectometry.model import PercentageFwhm +from easyreflectometry.project import Project +from easyreflectometry.sample import Layer +from easyreflectometry.sample import LayerMagnetism +from easyreflectometry.sample import Material +from easyreflectometry.sample import Multilayer +from easyreflectometry.sample import Sample + +print('All libraries imported successfully.') + +# %% [markdown] +# ## 1. Build a magnetic sample +# +# A single magnetic layer between two non-magnetic media: a thin Fe film +# (nuclear SLD 4, in units of $10^{-6}$ Å$^{-2}$) with an in-plane magnetic +# moment, sitting on a Si substrate below a vacuum superphase. +# +# Magnetism is attached with `Layer.magnetism = LayerMagnetism(rho_m=..., theta_m=...)` +# (or passed directly to `Layer(..., magnetism=...)`, as below). This +# automatically enables magnetism on the calculator once the layer has an +# interface — there is no separate "turn magnetism on" step required. +# +# `LayerMagnetism` exposes two ordinary, fittable `Parameter`s: +# +# | Parameter | Meaning | Unit | Default | +# |-----------|-----------------------------------------|-----------------------------|---------| +# | `rho_m` | Magnetic scattering length density | $10^{-6}$ Å$^{-2}$ | 0.0 | +# | `theta_m` | In-plane moment angle vs. the beam | degree | 270.0 | +# +# We wrap sample construction in a function so the same recipe can be reused +# below to build a "truth" model and, later, independent "fit starting point" +# models. + + +# %% +def build_magnetic_model(rho_m: float, theta_m: float, name: str) -> tuple[Model, Layer]: + """Build a Vacuum / Fe(magnetic) / Si model and return it with the magnetic layer. + + :param rho_m: Magnetic SLD of the Fe film, in 1e-6/angstrom^2. + :param theta_m: In-plane moment angle of the Fe film, in degrees. + :param name: Name for the model. + :return: The model (interface already switched to refl1d) and the Fe layer, + so its ``.magnetism`` parameters can be reached directly for fitting. + """ + vacuum = Material(sld=0, isld=0, name='Vacuum') + iron = Material(sld=4.0, isld=0, name='Fe') + silicon = Material(sld=2.047, isld=0, name='Si') + + superphase = Layer(material=vacuum, thickness=0, roughness=0, name='Vacuum Superphase') + film = Layer( + material=iron, + thickness=100, + roughness=0, + magnetism=LayerMagnetism(rho_m=rho_m, theta_m=theta_m, name='Fe film moment'), + name='Fe Film', + ) + subphase = Layer(material=silicon, thickness=0, roughness=0, name='Si Subphase') + + sample = Sample(Multilayer(superphase), Multilayer(film), Multilayer(subphase), name='Vacuum / Fe(magnetic) / Si') + model = Model(sample=sample, scale=1, background=0, name=name) + model.resolution_function = PercentageFwhm(0) # 0% resolution keeps this simulation clean + + interface = CalculatorFactory() + interface.switch('refl1d') # the only calculator that supports magnetism + model.interface = interface + + return model, film + + +RHO_M_TRUE = 2.5 # 1e-6 / angstrom^2 +THETA_M_TRUE = 45.0 # degrees -- canted, so all four channels differ + +truth_model, truth_film = build_magnetic_model(RHO_M_TRUE, THETA_M_TRUE, name='Truth model') + +print(f'Fe film magnetism: rho_m = {truth_film.magnetism.rho_m.value}, theta_m = {truth_film.magnetism.theta_m.value}') + +# %% [markdown] +# ## 2. All four polarization channels, one call +# +# `model.interface.polarized_reflectivity_profiles(q, model_name)` returns a +# dict with all four spin cross-sections at once: +# +# - `'pp'` — non-spin-flip, up-up +# - `'mm'` — non-spin-flip, down-down +# - `'pm'` — spin-flip, up-down +# - `'mp'` — spin-flip, down-up +# +# Internally this is a single `refl1d` kernel evaluation shared by all four +# channels (and cached per model state), so this costs about the same as +# computing one channel. For a single explicit channel without touching any +# calculator state, use `reflectivity_profile_channel(q, model_name, channel)` +# instead — both are stateless, unlike setting `interface.polarization_channel`. + +# %% +Q_PLOT = np.linspace(0.001, 0.3, 500) + +channels_truth = truth_model.interface.polarized_reflectivity_profiles(Q_PLOT, truth_model.unique_name) + +plt.figure(figsize=(8, 5)) +plt.semilogy(Q_PLOT, channels_truth['pp'], '-k', label='pp (non-spin-flip)', linewidth=2) +plt.semilogy(Q_PLOT, channels_truth['mm'], '-r', label='mm (non-spin-flip)', linewidth=2) +plt.semilogy(Q_PLOT, channels_truth['pm'], ':k', label='pm (spin-flip)', linewidth=2) +plt.semilogy(Q_PLOT, channels_truth['mp'], ':r', label='mp (spin-flip)', linewidth=2) +plt.xlabel('Q / Å⁻¹') +plt.ylabel('Reflectivity') +plt.title(f'Four polarization channels (rho_m={RHO_M_TRUE}, theta_m={THETA_M_TRUE}°)') +plt.legend() +plt.grid(True, alpha=0.3) +plt.show() + +# %% [markdown] +# ## 3. The spin-resolved depth profile +# +# `Project.magnetic_sld_data_for_model_at_index()` returns the nuclear SLD +# profile alongside the two potentials a neutron in each spin state actually +# experiences: `spin_up = sld + rho_m * cos(theta_m - guide_field_angle)` and +# `spin_down = sld - rho_m * cos(...)`. With a canted moment (not aligned with +# the guide field) the split between the two curves is reduced by that cosine +# factor rather than being the full `rho_m`. +# +# This wraps the model in a `Project`, the same object a GUI application uses +# to manage models, experiments and fitting — and is the entry point for the +# rest of this tutorial too. + +# %% +project = Project() +project.calculator = 'refl1d' +project.models = ModelCollection(truth_model) + +profiles = project.magnetic_sld_data_for_model_at_index(0) + +plt.figure(figsize=(8, 5)) +plt.plot(profiles['sld'].x, profiles['sld'].y, '-k', label='Nuclear SLD', linewidth=2) +plt.plot(profiles['spin_up'].x, profiles['spin_up'].y, '-b', label='Spin-up potential', linewidth=2) +plt.plot(profiles['spin_down'].x, profiles['spin_down'].y, '-r', label='Spin-down potential', linewidth=2) +plt.xlabel('z / Å') +plt.ylabel('SLD / 10⁻⁶ Å⁻²') +plt.title('Nuclear SLD and the two spin-dependent potentials') +plt.legend() +plt.grid(True, alpha=0.3) +plt.show() + +# The moment magnitude and direction are also available on their own, restricted +# to the depths that actually carry a moment (an angle is meaningless at zero SLD). +print(f'Peak magnetic SLD in the film: {profiles["rho_m"].y.max():.3f} (expected {RHO_M_TRUE})') +print(f'Moment angle inside the film: {profiles["theta_m"].y.mean():.1f}° (expected {THETA_M_TRUE}°)') + +# %% [markdown] +# ## 4. Loading a polarized experiment and forming the spin asymmetry +# +# A polarized measurement typically arrives as one data file per spin channel +# (e.g. `..._uu.dat` for up-up, `..._dd.dat` for down-down). Here we simulate +# that by writing the truth model's `pp`/`mm` reflectivity, with 1% relative +# noise, to two files, then loading them back exactly as a user would with +# real instrument output. +# +# The **spin asymmetry** $SA = (R^{++} - R^{--}) / (R^{++} + R^{--})$ is a +# common way to look at polarized data directly: it cancels the non-magnetic +# (nuclear) part of the reflectivity and isolates the magnetic signal, with +# `Project.spin_asymmetry_for_experiment_at_index()` handling the variance +# propagation and dropping points where the denominator is too small to be +# meaningful. + + +# %% +def add_relative_noise( + reflectivity: np.ndarray, relative_sigma: float, rng: np.random.Generator +) -> tuple[np.ndarray, np.ndarray]: + """Add reproducible Gaussian noise scaled to a fixed fraction of the signal. + + :param reflectivity: Noise-free reflectivity values. + :param relative_sigma: Standard deviation as a fraction of the signal (e.g. 0.01 for 1%). + :param rng: Seeded random number generator, for reproducible tutorial output. + :return: Noisy reflectivity and the per-point standard deviation (not variance). + """ + sigma = relative_sigma * np.abs(reflectivity) + noisy = rng.normal(loc=reflectivity, scale=sigma) + return np.clip(noisy, 1e-12, None), sigma + + +Q_DATA = np.linspace(0.01, 0.25, 60) # a more realistic, instrument-like grid +rng = np.random.default_rng(seed=42) # fixed seed: this tutorial's output is reproducible + +channels_data_grid = truth_model.interface.polarized_reflectivity_profiles(Q_DATA, truth_model.unique_name) +noisy_channels = { + channel: add_relative_noise(reflectivity, relative_sigma=0.01, rng=rng) + for channel, reflectivity in channels_data_grid.items() +} + +tmp_dir = Path(tempfile.mkdtemp(prefix='easyreflectometry_polarized_')) +pp_path = tmp_dir / 'fe_film_uu.txt' +mm_path = tmp_dir / 'fe_film_dd.txt' +np.savetxt(pp_path, np.column_stack([Q_DATA, noisy_channels['pp'][0], noisy_channels['pp'][1]])) +np.savetxt(mm_path, np.column_stack([Q_DATA, noisy_channels['mm'][0], noisy_channels['mm'][1]])) + +# The filename suffixes ('_uu', '_dd') are recognised automatically. +print(project.suggest_polarized_channel_assignment([pp_path, mm_path])) + +experiment_index = project.load_polarized_experiment({'pp': pp_path, 'mm': mm_path}) +loaded_channels = project.experiment_channels_at_index(experiment_index) +print(f'Loaded polarized experiment at index {experiment_index}, channels: {loaded_channels}') + +# %% +spin_asymmetry = project.spin_asymmetry_for_experiment_at_index(experiment_index) +measured, calculated = spin_asymmetry['measured'], spin_asymmetry['calculated'] + +plt.figure(figsize=(8, 5)) +plt.errorbar( + measured.x, + measured.y, + yerr=np.sqrt(measured.ye), + fmt='o', + color='0.3', + markersize=4, + alpha=0.6, + label='Measured (loaded files)', +) +plt.plot(calculated.x, calculated.y, '-r', linewidth=2, label='Calculated (truth model)') +plt.xlabel('Q / Å⁻¹') +plt.ylabel('Spin asymmetry') +plt.title('Spin asymmetry: loaded data vs. the model it was generated from') +plt.legend() +plt.grid(True, alpha=0.3) +plt.show() + +print( + f'{measured.x.size} of {Q_DATA.size} points kept ' + f'({spin_asymmetry["masked_points"]} masked: ' + f'{spin_asymmetry["low_significance_points"]} low-significance, ' + f'{spin_asymmetry["small_denominator_points"]} small-denominator).' +) + +# %% [markdown] +# ## 5. Fitting: recovering the moment magnitude from two channels +# +# The most common polarized experiment measures only the two non-spin-flip +# channels (`pp`, `mm`). If the moment's *direction* is already known from +# other means (sample geometry, prior characterization), that alone is enough +# to recover its *magnitude* — this is the everyday polarized-fitting case. +# +# `MultiFitter.fit_polarized()` takes a `PolarizedDataSet` and fits every +# channel it contains **simultaneously against one shared model**: any +# structural parameter (thickness, roughness, nuclear SLD, scale, background) +# is constrained jointly by all measured channels, and so is `rho_m`/`theta_m`. +# Internally `refl1d` still evaluates all cross-sections from a single kernel +# call, so fitting N channels together costs about as much as fitting one. +# +# We start from a deliberately wrong `rho_m` guess and fit against the two +# noisy channels loaded above; `theta_m` stays fixed at its (assumed known) +# true value. + +# %% +fit_model_2ch, fit_film_2ch = build_magnetic_model(rho_m=1.0, theta_m=THETA_M_TRUE, name='Fit: two channels (rho_m only)') + +fit_film_2ch.magnetism.rho_m.fixed = False +fit_film_2ch.magnetism.rho_m.bounds = (0.0, 5.0) +fit_film_2ch.magnetism.theta_m.fixed = True # moment direction assumed known + +initial_channels_2ch = fit_model_2ch.interface.polarized_reflectivity_profiles(Q_PLOT, fit_model_2ch.unique_name) + +fit_data_2ch = PolarizedDataSet( + name='Fe film (pp, mm)', + channels={ + 'pp': DataSet1D(name='pp', x=Q_DATA, y=noisy_channels['pp'][0], ye=noisy_channels['pp'][1] ** 2), + 'mm': DataSet1D(name='mm', x=Q_DATA, y=noisy_channels['mm'][0], ye=noisy_channels['mm'][1] ** 2), + }, + model=fit_model_2ch, # PolarizedDataSet.model must be the model the fitter is constructed with +) + +fitter_2ch = MultiFitter(fit_model_2ch) +results_2ch = fitter_2ch.fit_polarized(fit_data_2ch) + +print(f'Channels fitted: {list(results_2ch.keys())}, all successful: {all(r.success for r in results_2ch.values())}') +print(f'rho_m: {fit_film_2ch.magnetism.rho_m.value:.3f} (started at 1.0, true value {RHO_M_TRUE})') +print(f'Reduced chi^2: {fitter_2ch.reduced_chi:.3f}') + +fitted_channels_2ch = fit_model_2ch.interface.polarized_reflectivity_profiles(Q_PLOT, fit_model_2ch.unique_name) + +# %% +plt.figure(figsize=(8, 5)) +plt.errorbar( + Q_DATA, + noisy_channels['pp'][0], + yerr=noisy_channels['pp'][1], + fmt='o', + color='0.3', + markersize=4, + alpha=0.5, + label='pp (data)', +) +plt.errorbar( + Q_DATA, + noisy_channels['mm'][0], + yerr=noisy_channels['mm'][1], + fmt='s', + color='0.6', + markersize=4, + alpha=0.5, + label='mm (data)', +) +plt.semilogy(Q_PLOT, initial_channels_2ch['pp'], '--k', linewidth=1, alpha=0.6, label='pp (initial guess)') +plt.semilogy(Q_PLOT, initial_channels_2ch['mm'], '--r', linewidth=1, alpha=0.6, label='mm (initial guess)') +plt.semilogy(Q_PLOT, fitted_channels_2ch['pp'], '-k', linewidth=2, label='pp (fitted)') +plt.semilogy(Q_PLOT, fitted_channels_2ch['mm'], '-r', linewidth=2, label='mm (fitted)') +plt.yscale('log') +plt.xlabel('Q / Å⁻¹') +plt.ylabel('Reflectivity') +plt.title('Two-channel fit: rho_m recovered from pp and mm together') +plt.legend(fontsize=8) +plt.grid(True, alpha=0.3) +plt.show() + +# %% [markdown] +# ## 6. Fitting: recovering the full magnetization vector from four channels +# +# When the spin-flip channels (`pm`, `mp`) are also measured, `fit_polarized` +# can determine the moment's *direction* as well as its magnitude — both +# `rho_m` and `theta_m` are freed and constrained jointly by all four +# channels. This is the distinguishing capability of full polarization +# analysis over a non-spin-flip-only measurement. +# +# We start from wrong guesses for **both** parameters this time. + +# %% +fit_model_4ch, fit_film_4ch = build_magnetic_model(rho_m=1.5, theta_m=60.0, name='Fit: four channels (rho_m and theta_m)') + +fit_film_4ch.magnetism.rho_m.fixed = False +fit_film_4ch.magnetism.rho_m.bounds = (0.0, 5.0) +fit_film_4ch.magnetism.theta_m.fixed = False +fit_film_4ch.magnetism.theta_m.bounds = (0.0, 90.0) + +fit_data_4ch = PolarizedDataSet( + name='Fe film (pp, pm, mp, mm)', + channels={ + channel: DataSet1D(name=channel, x=Q_DATA, y=values[0], ye=values[1] ** 2) for channel, values in noisy_channels.items() + }, + model=fit_model_4ch, +) + +fitter_4ch = MultiFitter(fit_model_4ch) +results_4ch = fitter_4ch.fit_polarized(fit_data_4ch) + +print(f'Channels fitted: {list(results_4ch.keys())}, all successful: {all(r.success for r in results_4ch.values())}') +print(f'rho_m: {fit_film_4ch.magnetism.rho_m.value:.3f} (started at 1.5, true value {RHO_M_TRUE})') +print(f'theta_m: {fit_film_4ch.magnetism.theta_m.value:.1f}° (started at 60.0°, true value {THETA_M_TRUE}°)') +print(f'Reduced chi^2: {fitter_4ch.reduced_chi:.3f}') + +# %% [markdown] +# ## Summary +# +# New polarization API demonstrated in this tutorial: +# +# - `Layer(..., magnetism=LayerMagnetism(rho_m=, theta_m=))` — attach a +# fittable magnetic moment to a layer; only `refl1d` supports it. +# - `model.interface.polarized_reflectivity_profiles(q, model_name)` — all +# four spin cross-sections (`pp`, `pm`, `mp`, `mm`) from one call. +# - `model.interface.reflectivity_profile_channel(q, model_name, channel)` — +# a single explicit channel, without touching calculator state. +# - `Project.magnetic_sld_data_for_model_at_index()` — nuclear SLD plus the +# spin-up/spin-down potentials. +# - `Project.load_polarized_experiment({'pp': path, 'mm': path, ...})` and +# `Project.suggest_polarized_channel_assignment(paths)` — load and +# auto-detect per-channel data files. +# - `Project.spin_asymmetry_for_experiment_at_index()` — spin asymmetry with +# proper error propagation and physically-motivated point masking. +# - `PolarizedDataSet(channels={...}, model=model)` and +# `MultiFitter(model).fit_polarized(data)` — fit any number of measured +# channels simultaneously against one shared model. +# +# See `docs/docs/tutorials/simulation/magnetism.ipynb` for the basics of +# building magnetic samples and selecting a single channel, and +# `tests/test_polarized_fitting.py` for the full, exhaustively-tested API +# surface this tutorial draws on. diff --git a/docs/docs/tutorials/simulation/magnetism.ipynb b/docs/docs/tutorials/simulation/magnetism.ipynb index 8efdb9e4..63e49538 100644 --- a/docs/docs/tutorials/simulation/magnetism.ipynb +++ b/docs/docs/tutorials/simulation/magnetism.ipynb @@ -4,27 +4,28 @@ "cell_type": "markdown", "id": "a60117e3-d089-4375-ac7c-12a52ed47271", "metadata": {}, - "source": [ - "# Magnetism\n", - "\n", - "Magntism is only available in Refl1d and it does not support RepeatingMultilayer\n", - "\n" - ] + "source": "# Magnetism\n\nMagnetism is only available in Refl1d, and refl1d itself does not support magnetic layers inside a `RepeatingMultilayer` (it raises `NotImplementedError` rather than silently producing a wrong profile).\n\nWhen magnetism is enabled (`include_magnetism = True`) all four polarization channels are available: the non-spin-flip channels (`pp`, `mm`) and the spin-flip channels (`pm`, `mp`)." }, { "cell_type": "markdown", "id": "f5d0bd58", "metadata": {}, "source": [ - "## Setup\n", - "First configure matplotlib to place figures in notebook and import needed modules" + "## Setup" ] }, { "cell_type": "code", "execution_count": null, "id": "644e53e3", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:34.890141Z", + "iopub.status.busy": "2026-08-11T12:33:34.890141Z", + "iopub.status.idle": "2026-08-11T12:33:35.320450Z", + "shell.execute_reply": "2026-08-11T12:33:35.320450Z" + } + }, "outputs": [], "source": [ "%matplotlib inline" @@ -34,7 +35,14 @@ "cell_type": "code", "execution_count": null, "id": "29d5d62d-af4a-416d-bbe2-1338d32b30f5", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:35.320450Z", + "iopub.status.busy": "2026-08-11T12:33:35.320450Z", + "iopub.status.idle": "2026-08-11T12:33:38.194648Z", + "shell.execute_reply": "2026-08-11T12:33:38.194648Z" + } + }, "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", @@ -49,6 +57,7 @@ "from easyreflectometry.model import Model\n", "from easyreflectometry.model import PercentageFwhm\n", "from easyreflectometry.sample import Layer\n", + "from easyreflectometry.sample import LayerMagnetism\n", "from easyreflectometry.sample import Material\n", "from easyreflectometry.sample import Multilayer\n", "from easyreflectometry.sample import Sample" @@ -66,7 +75,14 @@ "cell_type": "code", "execution_count": null, "id": "549734c1-bbd9-41f3-8a20-d7a8ded37802", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:38.194648Z", + "iopub.status.busy": "2026-08-11T12:33:38.194648Z", + "iopub.status.idle": "2026-08-11T12:33:38.202401Z", + "shell.execute_reply": "2026-08-11T12:33:38.202401Z" + } + }, "outputs": [], "source": [ "print(f'numpy: {np.__version__}')\n", @@ -101,7 +117,14 @@ "cell_type": "code", "execution_count": null, "id": "0f95d620-35b7-4b47-a3b4-9e33d5525b50", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:38.202401Z", + "iopub.status.busy": "2026-08-11T12:33:38.202401Z", + "iopub.status.idle": "2026-08-11T12:33:38.215900Z", + "shell.execute_reply": "2026-08-11T12:33:38.214943Z" + } + }, "outputs": [], "source": [ "sld_4 = Material(sld=4.0, isld=0, name='Sld 4')\n", @@ -129,7 +152,14 @@ "cell_type": "code", "execution_count": null, "id": "2af8c30b", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:38.215900Z", + "iopub.status.busy": "2026-08-11T12:33:38.215900Z", + "iopub.status.idle": "2026-08-11T12:33:38.223629Z", + "shell.execute_reply": "2026-08-11T12:33:38.223629Z" + } + }, "outputs": [], "source": [ "two_layers = Multilayer([sld_4_layer, sld_8_layer], name='SLD 4/8 Layer')\n", @@ -154,7 +184,14 @@ "cell_type": "code", "execution_count": null, "id": "b0259cd0", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:38.223629Z", + "iopub.status.busy": "2026-08-11T12:33:38.223629Z", + "iopub.status.idle": "2026-08-11T12:33:38.229743Z", + "shell.execute_reply": "2026-08-11T12:33:38.229743Z" + } + }, "outputs": [], "source": [ "refl1d_sld_4 = refl1d.names.SLD(name='Sld 4', rho=4.0, irho=0)\n", @@ -180,7 +217,14 @@ "cell_type": "code", "execution_count": null, "id": "f1500603-d85d-4e16-b697-e1bf16502991", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:38.232600Z", + "iopub.status.busy": "2026-08-11T12:33:38.232600Z", + "iopub.status.idle": "2026-08-11T12:33:38.237388Z", + "shell.execute_reply": "2026-08-11T12:33:38.237388Z" + } + }, "outputs": [], "source": [ "interface = CalculatorFactory()" @@ -199,7 +243,14 @@ "cell_type": "code", "execution_count": null, "id": "18010202", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:38.237388Z", + "iopub.status.busy": "2026-08-11T12:33:38.237388Z", + "iopub.status.idle": "2026-08-11T12:33:38.244064Z", + "shell.execute_reply": "2026-08-11T12:33:38.244064Z" + } + }, "outputs": [], "source": [ "model_coords = np.linspace(\n", @@ -231,7 +282,14 @@ "cell_type": "code", "execution_count": null, "id": "cdf959c8", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:38.244064Z", + "iopub.status.busy": "2026-08-11T12:33:38.244064Z", + "iopub.status.idle": "2026-08-11T12:33:38.717693Z", + "shell.execute_reply": "2026-08-11T12:33:38.717693Z" + } + }, "outputs": [], "source": [ "# Refl1d\n", @@ -251,7 +309,7 @@ "model.interface = interface\n", "model.resolution_function = PercentageFwhm(0)\n", "model_interface = model.interface()\n", - "model_interface.magnetism = False\n", + "model_interface.include_magnetism = False\n", "model_data_no_magnetism_ref1d_easy = model.interface().reflectity_profile(\n", " model_coords,\n", " model.unique_name,\n", @@ -276,7 +334,14 @@ "cell_type": "code", "execution_count": null, "id": "bf311973", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:38.717693Z", + "iopub.status.busy": "2026-08-11T12:33:38.717693Z", + "iopub.status.idle": "2026-08-11T12:33:38.962554Z", + "shell.execute_reply": "2026-08-11T12:33:38.962554Z" + } + }, "outputs": [], "source": [ "# Without magnetic layers\n", @@ -293,14 +358,9 @@ "# With magnetic layers\n", "interface.switch('refl1d')\n", "model.interface = interface\n", + "sld_4_layer.magnetism = LayerMagnetism(rho_m=10, theta_m=70, name='Sld 4 moment')\n", + "sld_8_layer.magnetism = LayerMagnetism(rho_m=5, theta_m=175, name='Sld 8 moment')\n", "model_interface = model.interface()\n", - "model_interface.include_magnetism = True\n", - "model_interface._wrapper.update_layer(\n", - " list(model_interface._wrapper.storage['layer'].keys())[1], magnetism_rhoM=10, magnetism_thetaM=70\n", - ")\n", - "model_interface._wrapper.update_layer(\n", - " list(model_interface._wrapper.storage['layer'].keys())[2], magnetism_rhoM=5, magnetism_thetaM=175\n", - ")\n", "model_data_magnetism_layer_1 = model.interface().reflectity_profile(\n", " model_coords,\n", " model.unique_name,\n", @@ -331,7 +391,14 @@ "cell_type": "code", "execution_count": null, "id": "18cb7037", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:38.965319Z", + "iopub.status.busy": "2026-08-11T12:33:38.965319Z", + "iopub.status.idle": "2026-08-11T12:33:39.181473Z", + "shell.execute_reply": "2026-08-11T12:33:39.181473Z" + } + }, "outputs": [], "source": [ "# Refl1d model is inverted as compared to EasyReflectometry, so the order of the layers is reversed\n", @@ -343,8 +410,9 @@ ")\n", "model_name = model.unique_name\n", "storage = {'model': {model_name: {}}}\n", - "storage['model'][model_name]['scale'] = 10.0\n", - "storage['model'][model_name]['bkg'] = 20.0\n", + "# Match the EasyReflectometry model: scale=1, background=0\n", + "storage['model'][model_name]['scale'] = 1.0\n", + "storage['model'][model_name]['bkg'] = 0.0\n", "\n", "polarized_probe = _get_polarized_probe(\n", " q_array=model_coords, dq_array=np.zeros(len(model_coords)), model_name=model_name, storage=storage\n", @@ -357,14 +425,9 @@ "# EasyReflectometry\n", "interface.switch('refl1d')\n", "model.interface = interface\n", + "sld_4_layer.magnetism = LayerMagnetism(rho_m=10, theta_m=70, name='Sld 4 moment')\n", + "sld_8_layer.magnetism = LayerMagnetism(rho_m=5, theta_m=175, name='Sld 8 moment')\n", "model_interface = model.interface()\n", - "model_interface.include_magnetism = True\n", - "model_interface._wrapper.update_layer(\n", - " list(model_interface._wrapper.storage['layer'].keys())[1], magnetism_rhoM=10, magnetism_thetaM=70\n", - ")\n", - "model_interface._wrapper.update_layer(\n", - " list(model_interface._wrapper.storage['layer'].keys())[2], magnetism_rhoM=5, magnetism_thetaM=175\n", - ")\n", "model_data_magnetism_easy = model.interface().reflectity_profile(\n", " model_coords,\n", " model.unique_name,\n", @@ -386,7 +449,14 @@ "cell_type": "code", "execution_count": null, "id": "7033f755", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:39.183451Z", + "iopub.status.busy": "2026-08-11T12:33:39.183451Z", + "iopub.status.idle": "2026-08-11T12:33:39.188618Z", + "shell.execute_reply": "2026-08-11T12:33:39.188618Z" + } + }, "outputs": [], "source": [ "print(max(abs(model_data_magnetism_easy - model_data_magnetism_ref1d)))" @@ -406,22 +476,29 @@ "id": "7af84a69", "metadata": {}, "source": [ - "## Refl1d polarized probe for a single layer sample\n", - " This study is done with magnetism to show the results for polarized probe." + "## All polarization channels for a single layer sample\n", + "\n", + "This study is done with magnetism to show the reflectivity of all four spin cross-sections. First we compute the reference directly in Refl1d for a single magnetic layer on a silicon subphase." ] }, { "cell_type": "code", "execution_count": null, "id": "352c35e9", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:39.188618Z", + "iopub.status.busy": "2026-08-11T12:33:39.188618Z", + "iopub.status.idle": "2026-08-11T12:33:39.424010Z", + "shell.execute_reply": "2026-08-11T12:33:39.424010Z" + } + }, "outputs": [], "source": [ - "# The magnetism is set to 8.\n", - "# This would double (pp) and cancel out (mm) the magnitude of the reflectivity oscillations when its angle is set to 90.\n", - "# This would give the strongest spin-flipping (pm and mp) when its angle is set to 0.\n", - "# However we set the angle to 45, so the reflectivity oscillations are not doubled or cancelled out,\n", - "# and the spin-flipping is not maximized.\n", + "# The magnetic SLD is set to 8 and its angle to 45 degrees.\n", + "# An angle of 90 would double (pp) and cancel out (mm) the magnitude of the reflectivity oscillations,\n", + "# while an angle of 0 would give the strongest spin-flipping (pm and mp).\n", + "# At 45 degrees neither effect is maximized, so all four channels are distinct.\n", "refl1d_sample = (\n", " refl1d_si(0, 0) | refl1d_sld_8(150, 0, magnetism=refl1d.names.Magnetism(rhoM=8, thetaM=45)) | refl1d_vacuum(0, 0)\n", ")\n", @@ -432,69 +509,215 @@ "storage['model'][model_name]['bkg'] = 0.0\n", "\n", "polarized_probe = _get_polarized_probe(\n", - " q_array=model_coords, dq_array=np.zeros(len(model_coords)), model_name=model_name, storage=storage, all_polarizations=True\n", + " q_array=model_coords, dq_array=np.zeros(len(model_coords)), model_name=model_name, storage=storage\n", ")\n", "\n", - "experiment = refl1d.names.Experiment(probe=polarized_probe, sample=refl1d_sample)" + "experiment = refl1d.names.Experiment(probe=polarized_probe, sample=refl1d_sample)\n", + "# One reflectivity() call returns all four cross-sections, in the order pp, pm, mp, mm\n", + "raw_reflectivities = experiment.reflectivity()\n", + "raw_channels = {key: reflectivity for key, (_, reflectivity) in zip(('pp', 'pm', 'mp', 'mm'), raw_reflectivities)}\n", + "\n", + "plt.plot(model_coords, raw_channels['pp'], '-k', label='Refl1d pp', linewidth=4)\n", + "plt.plot(model_coords, raw_channels['mm'], '-r', label='Refl1d mm', linewidth=2)\n", + "plt.plot(model_coords, raw_channels['pm'], ':k', label='Refl1d pm', linewidth=4)\n", + "plt.plot(model_coords, raw_channels['mp'], ':r', label='Refl1d mp', linewidth=2)\n", + "\n", + "plot_apply_makeup()" + ] + }, + { + "cell_type": "markdown", + "id": "0b4b4d3f", + "metadata": {}, + "source": [ + "### All polarization channels in EasyReflectometry\n", + "\n", + "The same four channels are available through the EasyReflectometry API via `polarized_reflectivity_profiles`, which returns a dictionary keyed `pp`, `pm`, `mp`, `mm`. We build the equivalent single layer model and attach a `LayerMagnetism` to its layer, which makes it magnetic and enables `include_magnetism` automatically." ] }, { "cell_type": "code", "execution_count": null, "id": "239e2a04", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:39.424010Z", + "iopub.status.busy": "2026-08-11T12:33:39.424010Z", + "iopub.status.idle": "2026-08-11T12:33:39.723594Z", + "shell.execute_reply": "2026-08-11T12:33:39.723594Z" + } + }, "outputs": [], "source": [ - "model_data_magnetism_ref1d_raw_pp = experiment.reflectivity()[0][1]\n", - "model_data_magnetism_ref1d_raw_pm = experiment.reflectivity()[1][1]\n", - "model_data_magnetism_ref1d_raw_mp = experiment.reflectivity()[2][1]\n", - "model_data_magnetism_ref1d_raw_mm = experiment.reflectivity()[3][1]\n", + "# The single layer model matching the raw Refl1d sample above\n", + "vacuum_single = Material(sld=0, isld=0, name='Vacuum')\n", + "sld_8_single = Material(sld=8.0, isld=0, name='Sld 8')\n", + "si_single = Material(sld=2.047, isld=0, name='Si')\n", + "superphase_single = Layer(material=vacuum_single, thickness=0, roughness=0, name='Vacuum Superphase')\n", + "magnetic_layer = Layer(material=sld_8_single, thickness=150, roughness=0, name='Magnetic Layer')\n", + "subphase_single = Layer(material=si_single, thickness=0, roughness=0, name='Si Subphase')\n", + "single_layer_model = Model(\n", + " sample=Sample(\n", + " Multilayer(superphase_single),\n", + " Multilayer(magnetic_layer),\n", + " Multilayer(subphase_single),\n", + " name='Single Layer Sample',\n", + " ),\n", + " scale=1,\n", + " background=0,\n", + " name='Single Layer Model',\n", + ")\n", "\n", - "plt.plot(model_coords, model_data_magnetism_ref1d_raw_pp, '-k', label='Refl1d pp', linewidth=4)\n", - "plt.plot(model_coords, model_data_magnetism_ref1d_raw_mm, '-r', label='Refl1d mm', linewidth=2)\n", - "plt.plot(model_coords, model_data_magnetism_ref1d_raw_pm, ':k', label='Refl1d pm', linewidth=4)\n", - "plt.plot(model_coords, model_data_magnetism_ref1d_raw_mp, ':r', label='Refl1d mp', linewidth=2)\n", + "interface.switch('refl1d')\n", + "single_layer_model.interface = interface\n", + "single_layer_model.resolution_function = PercentageFwhm(0)\n", + "magnetic_layer.magnetism = LayerMagnetism(rho_m=8, theta_m=45, name='Magnetic layer moment')\n", + "model_interface = single_layer_model.interface()\n", + "\n", + "channels = single_layer_model.interface.polarized_reflectivity_profiles(\n", + " model_coords,\n", + " single_layer_model.unique_name,\n", + ")\n", + "\n", + "plt.plot(model_coords, channels['pp'], '-k', label='EasyReflectometry pp', linewidth=4)\n", + "plt.plot(model_coords, channels['mm'], '-r', label='EasyReflectometry mm', linewidth=2)\n", + "plt.plot(model_coords, channels['pm'], ':k', label='EasyReflectometry pm', linewidth=4)\n", + "plt.plot(model_coords, channels['mp'], ':r', label='EasyReflectometry mp', linewidth=2)\n", "\n", "plot_apply_makeup()" ] }, { "cell_type": "markdown", - "id": "ac52936c", + "id": "48fca800", "metadata": {}, "source": [ - "## Refl1 and Refnx in EasyReflectometry.\n", - "This study is done without magnetism as Refnx does not support this yet." + "The two models agree for every polarization channel." ] }, { "cell_type": "code", "execution_count": null, - "id": "e59d3153-f0da-4fce-a4f0-a424010acbec", + "id": "f70f3a34", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:39.723594Z", + "iopub.status.busy": "2026-08-11T12:33:39.723594Z", + "iopub.status.idle": "2026-08-11T12:33:39.729761Z", + "shell.execute_reply": "2026-08-11T12:33:39.729761Z" + } + }, + "outputs": [], + "source": [ + "for key in channels:\n", + " print(f'{key}: {max(abs(channels[key] - raw_channels[key]))}')" + ] + }, + { + "cell_type": "markdown", + "id": "30a07896", "metadata": {}, + "source": [ + "### Selecting a single channel\n", + "\n", + "`reflectity_profile` — the function used when fitting — returns the channel selected by `polarization_channel` (default `pp`). To fit against, say, `mm` data, select the `mm` channel.\n", + "\n", + "Note that the selected channel is state of the currently active calculator: it affects every model and fit using that calculator until it is changed, and `interface.switch(...)` constructs a fresh calculator, which resets the channel (along with `include_magnetism`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23beb5a6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:39.729761Z", + "iopub.status.busy": "2026-08-11T12:33:39.729761Z", + "iopub.status.idle": "2026-08-11T12:33:39.739748Z", + "shell.execute_reply": "2026-08-11T12:33:39.739748Z" + } + }, "outputs": [], "source": [ - "# Refnx\n", - "interface.switch('refnx')\n", - "model.interface = interface\n", - "model_interface = model.interface()\n", - "model_data_no_magnetism_refnx = model.interface().reflectity_profile(\n", + "model_interface.polarization_channel = 'mm'\n", + "reflectivity_mm = single_layer_model.interface().reflectity_profile(\n", " model_coords,\n", - " model.unique_name,\n", + " single_layer_model.unique_name,\n", ")\n", - "plt.plot(model_coords, model_data_no_magnetism_refnx, 'k-', label=f'EasyReflectometry ({model_interface.name})', linewidth=5)\n", + "print(f'mm channel reproduced: {max(abs(reflectivity_mm - channels[\"mm\"]))}')\n", "\n", - "# Refl1d\n", - "interface.switch('refl1d')\n", - "model.interface = interface\n", - "model_interface = model.interface()\n", - "model_data_no_magnetism_ref1d = model.interface().reflectity_profile(\n", - " model_coords,\n", - " model.unique_name,\n", - ")\n", - "plt.plot(model_coords, model_data_no_magnetism_ref1d, 'r-', label=f'EasyReflectometry ({model_interface.name})', linewidth=2)\n", + "# Reset to the default channel so later cells are unaffected\n", + "model_interface.polarization_channel = 'pp'" + ] + }, + { + "cell_type": "markdown", + "id": "a2d2d869", + "metadata": {}, + "source": [ + "### Magnetic SLD profile\n", "\n", - "plot_apply_makeup()" + "Alongside the nuclear SLD profile (`sld_profile`), the magnetic components are available through `magnetic_sld_profile`, which returns `z`, the nuclear SLD, the magnetic SLD (`rhoM`) and the magnetic angle (`thetaM`). Like the polarized reflectivities it requires `include_magnetism` to be enabled and is only available for the Refl1d calculator.\n", + "\n", + "Below we display the profiles for the single magnetic layer sample used above. The nuclear and magnetic SLD share the same unit ($10^{-6}$ Å$^{-2}$) so they are shown on one axis; the magnetic angle is constant (45 degrees) inside the layer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b6975f54", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:39.739748Z", + "iopub.status.busy": "2026-08-11T12:33:39.739748Z", + "iopub.status.idle": "2026-08-11T12:33:39.834787Z", + "shell.execute_reply": "2026-08-11T12:33:39.834787Z" + } + }, + "outputs": [], + "source": [ + "z, sld, sld_magnetic, theta_magnetic = single_layer_model.interface.magnetic_sld_profile(single_layer_model.unique_name)\n", + "\n", + "# In this sample the nuclear and magnetic SLD are both 8 inside the layer, so the curves overlap there\n", + "plt.plot(z, sld, '-k', label='Nuclear SLD', linewidth=4)\n", + "plt.plot(z, sld_magnetic, '-r', label='Magnetic SLD', linewidth=2)\n", + "plt.xlabel(r'$z$ / Å')\n", + "plt.ylabel(r'SLD / $10^{-6}$ Å$^{-2}$')\n", + "plt.legend()\n", + "plt.show()\n", + "\n", + "inside_layer = (z > 25) & (z < 125)\n", + "print(f'Magnetic angle inside the layer: {theta_magnetic[inside_layer].mean():.1f} degrees')" + ] + }, + { + "cell_type": "markdown", + "id": "f0223d02", + "metadata": {}, + "source": [ + "The profiles reproduce the ones determined directly by Refl1d for the equivalent sample (`magnetic_smooth_profile`). Since the Refl1d sample is built in the reverse order, its profiles are flipped before comparing." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6327bf82", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:39.834787Z", + "iopub.status.busy": "2026-08-11T12:33:39.834787Z", + "iopub.status.idle": "2026-08-11T12:33:39.843123Z", + "shell.execute_reply": "2026-08-11T12:33:39.843123Z" + } + }, + "outputs": [], + "source": [ + "# `experiment` is the raw Refl1d experiment for the single magnetic layer sample defined above\n", + "raw_z, raw_sld, _, raw_sld_magnetic, raw_theta_magnetic = experiment.magnetic_smooth_profile()\n", + "\n", + "print(f'sld: {max(abs(sld - raw_sld[::-1]))}')\n", + "print(f'magnetic sld: {max(abs(sld_magnetic - raw_sld_magnetic[::-1]))}')\n", + "print(f'magnetic angle: {max(abs(theta_magnetic - raw_theta_magnetic[::-1]))}')" ] }, { @@ -510,25 +733,40 @@ "cell_type": "code", "execution_count": null, "id": "b087e848", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:39.843123Z", + "iopub.status.busy": "2026-08-11T12:33:39.843123Z", + "iopub.status.idle": "2026-08-11T12:33:40.044974Z", + "shell.execute_reply": "2026-08-11T12:33:40.044974Z" + } + }, "outputs": [], "source": [ - "# With Magnitism\n", + "# The magnetic layers attached earlier in this tutorial live on the `Layer`\n", + "# objects themselves (unlike the old wrapper-storage workaround, which was\n", + "# scoped to one calculator instance and reset on every `interface.switch(...)`).\n", + "# Detach them so this section demonstrates its own point: a sample with no\n", + "# magnetic layers at all.\n", + "sld_4_layer.magnetism = None\n", + "sld_8_layer.magnetism = None\n", + "\n", + "# With Magnetism\n", "interface.switch('refl1d')\n", "model.interface = interface\n", "model_interface = model.interface()\n", - "model_interface.magnetism = True\n", + "model_interface.include_magnetism = True\n", "model_data_magnetism = model.interface().reflectity_profile(\n", " model_coords,\n", " model.unique_name,\n", ")\n", "plt.plot(model_coords, model_data_magnetism, '-k', label=f'With magnetism ({model_interface.name})', linewidth=4)\n", "\n", - "# Without Magnitism\n", + "# Without Magnetism\n", "interface.switch('refl1d')\n", "model.interface = interface\n", "model_interface = model.interface()\n", - "model_interface.magnetism = False\n", + "model_interface.include_magnetism = False\n", "model_data_no_magnetism = model.interface().reflectity_profile(\n", " model_coords,\n", " model.unique_name,\n", @@ -543,14 +781,21 @@ "id": "d1b41ed2", "metadata": {}, "source": [ - "We don't see any significant change in the determined reflectivity when enabling the ability to account for magnetism. However, there is a small difference, which is due to the fact that we are using `PolarizedQProbe` (Refl1d) when handling magnetic samples whereas non-magnetic samples are handled with a `QProbe` (Refl1d)." + "We don't see any change in the determined reflectivity when enabling the ability to account for magnetism for a sample without any magnetic layers, even though magnetic samples are handled with a `PolarizedQProbe` (Refl1d) whereas non-magnetic samples are handled with a `QProbe` (Refl1d)." ] }, { "cell_type": "code", "execution_count": null, "id": "00c25554", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-11T12:33:40.044974Z", + "iopub.status.busy": "2026-08-11T12:33:40.044974Z", + "iopub.status.idle": "2026-08-11T12:33:40.052467Z", + "shell.execute_reply": "2026-08-11T12:33:40.051582Z" + } + }, "outputs": [], "source": [ "print(max(abs(model_data_no_magnetism - model_data_magnetism)))" @@ -573,9 +818,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.12" + "version": "3.12.12" } }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 1cd19cde..f3ac980d 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -194,6 +194,7 @@ nav: - Solvated Material Fitting: tutorials/fitting/material_solvated.ipynb - Advanced Fitting: - Multi-Contrast Fitting: tutorials/advancedfitting/multi_contrast.ipynb + - Polarized Fitting: tutorials/advancedfitting/polarized_fitting.ipynb - API Reference: - API Reference: api-reference/index.md - Model: api-reference/model.md diff --git a/notebooks/polarized_demo_data/fe_on_si_dd.dat b/notebooks/polarized_demo_data/fe_on_si_dd.dat new file mode 100644 index 00000000..94cb89ab --- /dev/null +++ b/notebooks/polarized_demo_data/fe_on_si_dd.dat @@ -0,0 +1,161 @@ +# Qz (1/angstrom) R sR +8.000000000000000167e-03 9.075912642881344139e-01 2.704673059762681755e-02 +9.333333333333334106e-03 8.757161125297344695e-01 2.518846900075938366e-02 +1.066666666666666631e-02 6.977199840304085798e-01 2.087428696703839612e-02 +1.200000000000000025e-02 6.872509831174377082e-01 2.026445554463356807e-02 +1.333333333333333245e-02 6.631355988679124014e-01 1.992801525696327389e-02 +1.466666666666666639e-02 6.501014136246322517e-01 1.960325872581101386e-02 +1.600000000000000033e-02 6.261877501539732105e-01 1.923544318530388472e-02 +1.733333333333333254e-02 6.343907439684939309e-01 1.878917213001779843e-02 +1.866666666666666474e-02 5.922151008251805848e-01 1.823221530598403625e-02 +2.000000000000000042e-02 5.960631236889448736e-01 1.753182597463828069e-02 +2.133333333333333262e-02 5.732409575569683557e-01 1.665496617619377159e-02 +2.266666666666666829e-02 5.247578820550162781e-01 1.557151300442079396e-02 +2.400000000000000050e-02 4.712753742667411538e-01 1.426072465920071378e-02 +2.533333333333333270e-02 4.298037665936796370e-01 1.272086791267182382e-02 +2.666666666666666491e-02 3.626175654261608594e-01 1.098020577277717021e-02 +2.800000000000000058e-02 3.120130601042385710e-01 9.104851279847682097e-03 +2.933333333333333279e-02 2.267220914996334880e-01 7.197086378452600963e-03 +3.066666666666666499e-02 1.775178148014185664e-01 5.379698437054512838e-03 +3.200000000000000067e-02 1.181310173097377125e-01 3.769033640753254900e-03 +3.333333333333333287e-02 7.792836377261480596e-02 2.447631659821120943e-03 +3.466666666666666508e-02 5.027360606248993530e-02 1.448924192475278426e-03 +3.600000000000000422e-02 2.598520932519453666e-02 7.591683953662536856e-04 +3.733333333333332948e-02 1.082782187122047213e-02 3.320006942449972034e-04 +3.866666666666666863e-02 3.390612178886910372e-03 1.065197543374674642e-04 +4.000000000000000083e-02 6.634734704890265719e-04 2.184720411327092837e-05 +4.133333333333333304e-02 8.402822722116700099e-04 2.562630155983095458e-05 +4.266666666666666524e-02 2.761189837369125693e-03 7.722798210359995330e-05 +4.399999999999999745e-02 4.984147983628604318e-03 1.475987877702071994e-04 +4.533333333333332965e-02 7.130190399400800419e-03 2.175578930081850085e-04 +4.666666666666666879e-02 9.315547059324427726e-03 2.756208378691675140e-04 +4.800000000000000100e-02 1.003920070040243209e-02 3.159726242251264801e-04 +4.933333333333333320e-02 1.112594162420929046e-02 3.367822456192096385e-04 +5.066666666666666541e-02 1.132996142293247153e-02 3.388874925461203276e-04 +5.199999999999999761e-02 1.079869901323585180e-02 3.247999349804731625e-04 +5.333333333333332982e-02 1.016770659568656983e-02 2.979622858794108331e-04 +5.466666666666666202e-02 8.830283975467438998e-03 2.621975650898551008e-04 +5.600000000000000117e-02 7.524710540696799392e-03 2.213042152714181170e-04 +5.733333333333333337e-02 5.835794375082871931e-03 1.787655487150832450e-04 +5.866666666666666557e-02 4.708583433713307148e-03 1.375525977593421471e-04 +5.999999999999999778e-02 3.496438325611750386e-03 1.000060435868242991e-04 +6.133333333333332998e-02 2.193770931717122993e-03 6.778600474993069151e-05 +6.266666666666666219e-02 1.358795672471345200e-03 4.187914831486427934e-05 +6.400000000000000133e-02 7.853249876843387258e-04 2.265200387178431808e-05 +6.533333333333332660e-02 3.293824670891950385e-04 9.938524318625599184e-06 +6.666666666666666574e-02 1.094068554305717308e-04 3.149562947669204988e-06 +6.800000000000000488e-02 4.579681042216983595e-05 1.392389778723863959e-06 +6.933333333333333015e-02 1.248796610637395508e-04 3.589694748014370890e-06 +7.066666666666665542e-02 2.874470694658368109e-04 8.589530042765663588e-06 +7.200000000000000844e-02 5.126077964958570014e-04 1.526001661087636688e-05 +7.333333333333333370e-02 7.874764261774640402e-04 2.256505436841721351e-05 +7.466666666666665897e-02 9.766001106681053163e-04 2.961946677919368234e-05 +7.599999999999998423e-02 1.157173229345322303e-03 3.572381091442601147e-05 +7.733333333333333726e-02 1.327900995494995596e-03 4.038042628291313278e-05 +7.866666666666666252e-02 1.462689364936814581e-03 4.329319153718547912e-05 +7.999999999999998779e-02 1.409018005772228778e-03 4.435396905627936038e-05 +8.133333333333334081e-02 1.481769978748375717e-03 4.361892548833955793e-05 +8.266666666666666607e-02 1.353690733781721641e-03 4.127790251118508715e-05 +8.399999999999999134e-02 1.297175308564354644e-03 3.761984328443019373e-05 +8.533333333333334436e-02 1.020887830362373315e-03 3.299671678670470522e-05 +8.666666666666666963e-02 9.044813985473879655e-04 2.779020672111869435e-05 +8.799999999999999489e-02 7.081969582030287706e-04 2.237810772498295098e-05 +8.933333333333332016e-02 5.561639284949144524e-04 1.710899656797894980e-05 +9.066666666666667318e-02 4.124016110169756878e-04 1.228080225182807722e-05 +9.199999999999999845e-02 2.694131913742567504e-04 8.126088081576757726e-06 +9.333333333333332371e-02 1.589095847446595053e-04 4.803802818737303555e-06 +9.466666666666667673e-02 7.952535288150432656e-05 2.397208566565326355e-06 +9.600000000000000200e-02 3.076423342320826502e-05 9.173297799956004235e-07 +9.733333333333332726e-02 1.005251023256088463e-05 3.109844786323962683e-07 +9.866666666666665253e-02 1.607610631621909667e-05 4.722685047958857686e-07 +1.000000000000000056e-01 4.270820470822345427e-05 1.256271490261844000e-06 +1.013333333333333308e-01 8.408706070466543899e-05 2.493805529005849394e-06 +1.026666666666666561e-01 1.357632565588043198e-04 4.006013285794612694e-06 +1.040000000000000091e-01 1.889277283032915380e-04 5.617874673711491538e-06 +1.053333333333333344e-01 2.535854776939441531e-04 7.169830416826538728e-06 +1.066666666666666596e-01 2.834895567475302354e-04 8.526966173941271989e-06 +1.079999999999999849e-01 3.165709134255915796e-04 9.585433172802807031e-06 +1.093333333333333379e-01 3.347902142037292557e-04 1.027600399898480948e-05 +1.106666666666666632e-01 3.412563648594390620e-04 1.056486233606250258e-05 +1.119999999999999885e-01 3.353893732424267221e-04 1.045189380634267694e-05 +1.133333333333333415e-01 3.233706619271815397e-04 9.966875831629191658e-06 +1.146666666666666667e-01 3.048207765620222862e-04 9.164054863718112327e-06 +1.159999999999999920e-01 2.732346614974670118e-04 8.115649185306674919e-06 +1.173333333333333173e-01 2.305140128431734333e-04 6.904826573947082877e-06 +1.186666666666666703e-01 1.829880995447076856e-04 5.618681964024802284e-06 +1.199999999999999956e-01 1.486311764967542036e-04 4.341685740344124731e-06 +1.213333333333333208e-01 1.073289623471351128e-04 3.149994494995711424e-06 +1.226666666666666738e-01 6.989429649780126480e-05 2.106919880718955435e-06 +1.239999999999999991e-01 4.116899314943323328e-05 1.259745030541038548e-06 +1.253333333333333244e-01 2.161552831842888031e-05 6.379694419153882662e-07 +1.266666666666666496e-01 8.479532071820358884e-06 2.529594711760443070e-07 +1.280000000000000027e-01 3.160320019205485887e-06 9.911555885444282819e-08 +1.293333333333333279e-01 5.156191795148754930e-06 1.550018658500189904e-07 +1.306666666666666532e-01 1.302114699187069443e-05 3.875875551631081219e-07 +1.320000000000000062e-01 2.448191181775688001e-05 7.548308766651755472e-07 +1.333333333333333315e-01 4.055071110271158921e-05 1.209632113147993333e-06 +1.346666666666666567e-01 5.430723368147500208e-05 1.703568712487504400e-06 +1.360000000000000098e-01 7.593907561589344907e-05 2.190370009653334060e-06 +1.373333333333333350e-01 9.091137562687272972e-05 2.628918504551843803e-06 +1.386666666666666603e-01 9.876665112112765978e-05 2.985617136047146022e-06 +1.400000000000000133e-01 1.090434830349768235e-04 3.236020124501763433e-06 +1.413333333333333386e-01 1.040784406922520204e-04 3.365684317789212350e-06 +1.426666666666666639e-01 1.084446320665948850e-04 3.370254540572536249e-06 +1.439999999999999891e-01 1.075386877360002690e-04 3.254846793366482747e-06 +1.453333333333333421e-01 9.784288464121572589e-05 3.032834603029489718e-06 +1.466666666666666674e-01 9.275195865909300664e-05 2.724174538521047095e-06 +1.479999999999999927e-01 8.314801822003993546e-05 2.353425867014385280e-06 +1.493333333333333457e-01 6.262927242161476637e-05 1.947626347500982468e-06 +1.506666666666666710e-01 4.985457261801826958e-05 1.534181812229199650e-06 +1.519999999999999962e-01 3.823191202299757656e-05 1.138912706537873486e-06 +1.533333333333333492e-01 2.740965478464539993e-05 7.843779252809585293e-07 +1.546666666666666745e-01 1.568836390366752764e-05 4.885672798968271187e-07 +1.559999999999999998e-01 8.866456616526149662e-06 2.640211742127980506e-07 +1.573333333333333250e-01 4.127225828926081602e-06 1.174020483931366809e-07 +1.586666666666666781e-01 1.568531262286780794e-06 4.950925901345495624e-08 +1.600000000000000033e-01 1.785293006053637639e-06 5.569943511660690089e-08 +1.613333333333333286e-01 4.168008252489919221e-06 1.266497375405589417e-07 +1.626666666666666816e-01 8.182944871264921272e-06 2.493831250965557545e-07 +1.640000000000000069e-01 1.394736579666988838e-05 4.084634364659220295e-07 +1.653333333333333321e-01 1.971738563115660661e-05 5.872640271270070076e-07 +1.666666666666666574e-01 2.427522111972123093e-05 7.692165387495262632e-07 +1.680000000000000104e-01 3.178245932529219920e-05 9.389553589627007831e-07 +1.693333333333333357e-01 3.548210427269819290e-05 1.083231375481271260e-06 +1.706666666666666610e-01 4.124756671870089794e-05 1.191858190928503467e-06 +1.720000000000000140e-01 4.114124414311627793e-05 1.257920990354555467e-06 +1.733333333333333393e-01 4.179113020179383182e-05 1.278144495652170089e-06 +1.746666666666666645e-01 4.243796202477311701e-05 1.252800954782821035e-06 +1.759999999999999898e-01 4.041810378176311592e-05 1.185411655127856387e-06 +1.773333333333333428e-01 3.655984369932937427e-05 1.082246689385805984e-06 +1.786666666666666681e-01 3.011829789651725348e-05 9.516730637963951386e-07 +1.799999999999999933e-01 2.721251947756813565e-05 8.034077531126447617e-07 +1.813333333333333464e-01 2.092119218494401328e-05 6.477344750345810442e-07 +1.826666666666666716e-01 1.660776611688476398e-05 4.947409616157709699e-07 +1.839999999999999969e-01 1.128412102902365682e-05 3.536277928505755650e-07 +1.853333333333333222e-01 7.841308266496928731e-06 2.321310905890118204e-07 +1.866666666666666752e-01 4.426575290143259573e-06 1.360903689794665034e-07 +1.880000000000000004e-01 2.217283777652154650e-06 6.918051302336152221e-08 +1.893333333333333257e-01 1.117228285514961176e-06 3.281414660715856888e-08 +1.906666666666666787e-01 8.799482043854008228e-07 2.620845226422373283e-08 +1.920000000000000040e-01 1.524709408323732203e-06 4.659961472149735373e-08 +1.933333333333333293e-01 3.115966333801529127e-06 8.957912985717266193e-08 +1.946666666666666545e-01 4.918104415699437457e-06 1.495197192619391863e-07 +1.960000000000000075e-01 7.342224987678191212e-06 2.200547862104041078e-07 +1.973333333333333328e-01 9.898358472380025657e-06 2.945743044174480836e-07 +1.986666666666666581e-01 1.199615466869351067e-05 3.667016150254416437e-07 +2.000000000000000111e-01 1.456024485260493697e-05 4.307195176666933625e-07 +2.013333333333333364e-01 1.580691336393115994e-05 4.819198389956592358e-07 +2.026666666666666616e-01 1.701583522311326881e-05 5.168578111597016410e-07 +2.039999999999999869e-01 1.851032070732363719e-05 5.335005051206870393e-07 +2.053333333333333399e-01 1.715605943587205967e-05 5.312666428372068491e-07 +2.066666666666666652e-01 1.579925214859981974e-05 5.109627938210401376e-07 +2.079999999999999905e-01 1.658552037802851100e-05 4.746277493330595449e-07 +2.093333333333333435e-01 1.526098059118070652e-05 4.253023617610078228e-07 +2.106666666666666687e-01 1.207623756549564588e-05 3.667460540832117272e-07 +2.119999999999999940e-01 9.516911054610977533e-06 3.031201720409783612e-07 +2.133333333333333470e-01 7.883129891417216319e-06 2.387174348937093517e-07 +2.146666666666666723e-01 5.874845517182401667e-06 1.777718369021740767e-07 +2.159999999999999976e-01 4.134533427932239587e-06 1.247467744346401067e-07 +2.173333333333333228e-01 2.702962831977833632e-06 8.389098142566779552e-08 +2.186666666666666758e-01 1.919475662810552952e-06 5.660017207259385942e-08 +2.200000000000000011e-01 1.362130841063948462e-06 4.023088338049808278e-08 diff --git a/notebooks/polarized_demo_data/fe_on_si_du.dat b/notebooks/polarized_demo_data/fe_on_si_du.dat new file mode 100644 index 00000000..310d49b0 --- /dev/null +++ b/notebooks/polarized_demo_data/fe_on_si_du.dat @@ -0,0 +1,161 @@ +# Qz (1/angstrom) R sR +8.000000000000000167e-03 1.007528046687608952e-01 2.953269400266327581e-03 +9.333333333333334106e-03 1.572583517379290974e-01 4.744889095853950176e-03 +1.066666666666666631e-02 2.190450883612067301e-01 6.348290158922448474e-03 +1.200000000000000025e-02 1.817726548209979698e-01 5.333047055155122271e-03 +1.333333333333333245e-02 1.700993783671900794e-01 4.838694817206525695e-03 +1.466666666666666639e-02 1.539094991606667073e-01 4.518230715159094954e-03 +1.600000000000000033e-02 1.354415289972342729e-01 4.264358150216949334e-03 +1.733333333333333254e-02 1.342962108592117287e-01 4.036995014532265835e-03 +1.866666666666666474e-02 1.227038410099842297e-01 3.815260616836199312e-03 +2.000000000000000042e-02 1.176659451520821187e-01 3.585730713023881338e-03 +2.133333333333333262e-02 1.163308261281947587e-01 3.338564763918748599e-03 +2.266666666666666829e-02 1.041684027537248536e-01 3.066403995854867417e-03 +2.400000000000000050e-02 9.022380436698591855e-02 2.764683779577930840e-03 +2.533333333333333270e-02 7.862819405015539220e-02 2.432831941156086541e-03 +2.666666666666666491e-02 6.926198949118184123e-02 2.075818194789780333e-03 +2.800000000000000058e-02 5.476494393947105993e-02 1.705182012707162978e-03 +2.933333333333333279e-02 4.371604781302679960e-02 1.338429655907041925e-03 +3.066666666666666499e-02 3.351430919901606659e-02 9.961054459564167105e-04 +3.200000000000000067e-02 2.404199231537985579e-02 6.970987714316889813e-04 +3.333333333333333287e-02 1.541282094899675166e-02 4.540916239051047713e-04 +3.466666666666666508e-02 8.419121830050425859e-03 2.712167368097655575e-04 +3.600000000000000422e-02 4.866438123017920343e-03 1.446721420904415584e-04 +3.733333333333332948e-02 2.184334737089637605e-03 6.538873658233344843e-05 +3.866666666666666863e-02 7.480584982595504936e-04 2.216651973245298166e-05 +4.000000000000000083e-02 1.437357498433520478e-04 4.112664642903217370e-06 +4.133333333333333304e-02 6.269007326684950790e-05 2.004793178707047923e-06 +4.266666666666666524e-02 2.865889630817411481e-04 8.752884696178654547e-06 +4.399999999999999745e-02 6.550303833826847787e-04 1.930862386371530147e-05 +4.533333333333332965e-02 9.631658244066027561e-04 3.033427008241416770e-05 +4.666666666666666879e-02 1.385728562476485660e-03 3.980916777581890430e-05 +4.800000000000000100e-02 1.573018053011269015e-03 4.667475297322238204e-05 +4.933333333333333320e-02 1.727633637372023986e-03 5.054528427436336515e-05 +5.066666666666666541e-02 1.686791702169595239e-03 5.148561259012480502e-05 +5.199999999999999761e-02 1.702067580113456003e-03 4.984516191675345741e-05 +5.333333333333332982e-02 1.587135771138159556e-03 4.613524715474374467e-05 +5.466666666666666202e-02 1.374157060934677209e-03 4.093870012507680326e-05 +5.600000000000000117e-02 1.169622308953436338e-03 3.484364780409830557e-05 +5.733333333333333337e-02 9.542071375089862875e-04 2.839591481744609854e-05 +5.866666666666666557e-02 7.164976311395233151e-04 2.206645809742515836e-05 +5.999999999999999778e-02 5.386531942509326619e-04 1.623143388812568893e-05 +6.133333333333332998e-02 3.703997764791592249e-04 1.116307189423245952e-05 +6.266666666666666219e-02 2.370178599298105031e-04 7.029681693010564393e-06 +6.400000000000000133e-02 1.340037966314343190e-04 3.903043182621346832e-06 +6.533333333333332660e-02 5.716856891883270394e-05 1.771306833428139153e-06 +6.666666666666666574e-02 1.844625167704147395e-05 5.554707159785059965e-07 +6.800000000000000488e-02 4.441357711528022514e-06 1.275710080193205838e-07 +6.933333333333333015e-02 1.071971812064922420e-05 3.289291796014142583e-07 +7.066666666666665542e-02 3.209209197047745474e-05 9.871123536089981223e-07 +7.200000000000000844e-02 6.474504632760995197e-05 1.930634013376320455e-06 +7.333333333333333370e-02 1.025604519999324173e-04 3.000798661515613774e-06 +7.466666666666665897e-02 1.353942129095416121e-04 4.060434544764981782e-06 +7.599999999999998423e-02 1.732954268357779178e-04 4.999537169031312049e-06 +7.733333333333333726e-02 1.961847617845852187e-04 5.738052975700706681e-06 +7.866666666666666252e-02 2.127803051638275087e-04 6.226169716693550195e-06 +7.999999999999998779e-02 2.183218588359995497e-04 6.442557935201045315e-06 +8.133333333333334081e-02 2.279107827402499539e-04 6.391039775753467422e-06 +8.266666666666666607e-02 2.019546328883078804e-04 6.096159940720307629e-06 +8.399999999999999134e-02 1.753877097453329192e-04 5.598109401755303868e-06 +8.533333333333334436e-02 1.728490010012067517e-04 4.947365070275968397e-06 +8.666666666666666963e-02 1.380682774156448791e-04 4.199714530999939949e-06 +8.799999999999999489e-02 1.140730814567600827e-04 3.411152544137300506e-06 +8.933333333333332016e-02 9.124993726152226591e-05 2.634016764123891206e-06 +9.066666666666667318e-02 6.072257135519556961e-05 1.913662668736402492e-06 +9.199999999999999845e-02 4.126117502474644250e-05 1.286128630253563915e-06 +9.333333333333332371e-02 2.464822992743742941e-05 7.767612157382617825e-07 +9.466666666666667673e-02 1.300805868990023634e-05 3.997658169802781140e-07 +9.600000000000000200e-02 5.356127149208341696e-06 1.585921273381317201e-07 +9.733333333333332726e-02 1.591982395813902045e-06 4.702005169047973194e-08 +9.866666666666665253e-02 1.706746190254171456e-06 5.078149721359232735e-08 +1.000000000000000056e-01 4.773339088185987279e-06 1.495380398780969620e-07 +1.013333333333333308e-01 9.897442493022866688e-06 3.190332704228019704e-07 +1.026666666666666561e-01 1.780398721376930520e-05 5.332500947762536672e-07 +1.040000000000000091e-01 2.518591650492778427e-05 7.664249224476511593e-07 +1.053333333333333344e-01 3.361698178729349720e-05 9.947995508378553946e-07 +1.066666666666666596e-01 4.077510951086607538e-05 1.198024554907400912e-06 +1.079999999999999849e-01 4.552677179032260055e-05 1.360162230472498603e-06 +1.093333333333333379e-01 5.012660576403459684e-05 1.470270143505473408e-06 +1.106666666666666632e-01 5.110153064570546782e-05 1.522576166114266844e-06 +1.119999999999999885e-01 5.134643196822854741e-05 1.516281145477262508e-06 +1.133333333333333415e-01 4.747617465830521113e-05 1.455045182847769484e-06 +1.146666666666666667e-01 4.463245108405789895e-05 1.346227467082781511e-06 +1.159999999999999920e-01 4.023471123264161690e-05 1.199957647518279284e-06 +1.173333333333333173e-01 3.511423690670630504e-05 1.028119077817123801e-06 +1.186666666666666703e-01 2.777866275678767680e-05 8.433213933226984112e-07 +1.199999999999999956e-01 2.227397560266068137e-05 6.579324820953016552e-07 +1.213333333333333208e-01 1.597916664254661170e-05 4.832288283145328242e-07 +1.226666666666666738e-01 1.091834395770022714e-05 3.287094353144487512e-07 +1.239999999999999991e-01 6.887338736991865434e-06 2.016031524920293201e-07 +1.253333333333333244e-01 3.340351128440268230e-06 1.065833450927718424e-07 +1.266666666666666496e-01 1.463717126803408988e-06 4.568853130034033272e-08 +1.280000000000000027e-01 5.881997961615234001e-07 1.846714590461363399e-08 +1.293333333333333279e-01 6.857057984571196680e-07 2.211974366523686281e-08 +1.306666666666666532e-01 1.701367205018803992e-06 5.210116722106288193e-08 +1.320000000000000062e-01 3.492999864241714603e-06 1.024858058137199515e-07 +1.333333333333333315e-01 5.503821082625335375e-06 1.665379527543910645e-07 +1.346666666666666567e-01 7.955450596612713394e-06 2.372557130903818383e-07 +1.360000000000000098e-01 1.059807236400316402e-05 3.078816679312774017e-07 +1.373333333333333350e-01 1.290597782263722626e-05 3.723484773852549928e-07 +1.386666666666666603e-01 1.415840904620301583e-05 4.256350973333881385e-07 +1.400000000000000133e-01 1.609534352654533548e-05 4.640176725731832458e-07 +1.413333333333333386e-01 1.621829397454471562e-05 4.852078020329178800e-07 +1.426666666666666639e-01 1.587033743462086622e-05 4.883791582282670671e-07 +1.439999999999999891e-01 1.552122944192228855e-05 4.740908767816654892e-07 +1.453333333333333421e-01 1.414653781401564062e-05 4.441223134538681079e-07 +1.466666666666666674e-01 1.301826040420639851e-05 4.012384147119141744e-07 +1.479999999999999927e-01 1.150534861508465464e-05 3.489079040197437945e-07 +1.493333333333333457e-01 9.933765042361007673e-06 2.909977092754138739e-07 +1.506666666666666710e-01 8.113854683736707309e-06 2.314666173503457221e-07 +1.519999999999999962e-01 5.562031195432281614e-06 1.740792077582149680e-07 +1.533333333333333492e-01 4.119918280027462889e-06 1.221579385885165474e-07 +1.546666666666666745e-01 2.531339545498620342e-06 7.838714440552982691e-08 +1.559999999999999998e-01 1.510475390185554071e-06 4.467800612282128617e-08 +1.573333333333333250e-01 7.337241119796921424e-07 2.209862841490182638e-08 +1.586666666666666781e-01 3.423860559455051005e-07 1.086856102959510645e-08 +1.600000000000000033e-01 3.567575149171467956e-07 1.041274193708904071e-08 +1.613333333333333286e-01 6.370957478418122976e-07 1.946618409280882712e-08 +1.626666666666666816e-01 1.187928772811707295e-06 3.621796661461974208e-08 +1.640000000000000069e-01 1.886817482226232735e-06 5.848134113771266685e-08 +1.653333333333333321e-01 2.740990010100866872e-06 8.387606143231342599e-08 +1.666666666666666574e-01 3.714050285203545527e-06 1.100093500775490443e-07 +1.680000000000000104e-01 4.462623715355420957e-06 1.346431249439114636e-07 +1.693333333333333357e-01 5.245524653001244682e-06 1.558292846488293914e-07 +1.706666666666666610e-01 5.797230945146113261e-06 1.720488808566032506e-07 +1.720000000000000140e-01 6.315639180347299995e-06 1.822485165816315026e-07 +1.733333333333333393e-01 6.132828576201715253e-06 1.858965400689284567e-07 +1.746666666666666645e-01 5.828917138659435816e-06 1.829743260720297752e-07 +1.759999999999999898e-01 5.983528676165092459e-06 1.739369770554233248e-07 +1.773333333333333428e-01 5.268554844138802376e-06 1.596442507501743892e-07 +1.786666666666666681e-01 4.866416974192439597e-06 1.412687952699017713e-07 +1.799999999999999933e-01 4.052403967000443886e-06 1.201897786982718147e-07 +1.813333333333333464e-01 3.249843487696617775e-06 9.788037836311576685e-08 +1.826666666666666716e-01 2.553015230252052794e-06 7.579736806318378599e-08 +1.839999999999999969e-01 1.950528170525877562e-06 5.528027007827940618e-08 +1.853333333333333222e-01 1.326693994938500607e-06 3.746631560777318907e-08 +1.866666666666666752e-01 7.758080648631726058e-07 2.322589876926246457e-08 +1.880000000000000004e-01 4.394834729298315398e-07 1.312144622023900073e-08 +1.893333333333333257e-01 2.543140075460072141e-07 7.390792205372861249e-09 +1.906666666666666787e-01 1.934438757758805036e-07 5.954377832495878448e-09 +1.920000000000000040e-01 2.842791322772257605e-07 8.444000575771938985e-09 +1.933333333333333293e-01 4.746155226669125089e-07 1.424952167638332013e-08 +1.946666666666666545e-01 7.597199115755520296e-07 2.257896566540885911e-08 +1.960000000000000075e-01 1.057121768979611590e-06 3.252685916284303909e-08 +1.973333333333333328e-01 1.369599608430002247e-06 4.314546721810592838e-08 +1.986666666666666581e-01 1.672859432810189042e-06 5.351377826236124305e-08 +2.000000000000000111e-01 2.023149424947413852e-06 6.279962173735473534e-08 +2.013333333333333364e-01 2.311453826628649454e-06 7.031111390500286278e-08 +2.026666666666666616e-01 2.495675246732672360e-06 7.553464103958034328e-08 +2.039999999999999869e-01 2.756666934061640622e-06 7.815772084942974035e-08 +2.053333333333333399e-01 2.688893360609558629e-06 7.807624648935259530e-08 +2.066666666666666652e-01 2.440362129845528739e-06 7.538673102154169124e-08 +2.079999999999999905e-01 2.369971933634048958e-06 7.036516117011379262e-08 +2.093333333333333435e-01 2.088673000388599980e-06 6.343487876882732770e-08 +2.106666666666666687e-01 1.821873819654762058e-06 5.512649403226413222e-08 +2.119999999999999940e-01 1.542950070958765300e-06 4.603257164174993808e-08 +2.133333333333333470e-01 1.248412959336401762e-06 3.676939240707617536e-08 +2.146666666666666723e-01 9.221627784440944102e-07 2.794934492486567134e-08 +2.159999999999999976e-01 6.957246227612103214e-07 2.022620820483283792e-08 +2.173333333333333228e-01 4.582678415428967050e-07 1.423572479592624386e-08 +2.186666666666666758e-01 3.403128237926930089e-07 1.020744354514502879e-08 +2.200000000000000011e-01 2.793881066986614425e-07 7.775682608413154104e-09 diff --git a/notebooks/polarized_demo_data/fe_on_si_ud.dat b/notebooks/polarized_demo_data/fe_on_si_ud.dat new file mode 100644 index 00000000..83bbb25e --- /dev/null +++ b/notebooks/polarized_demo_data/fe_on_si_ud.dat @@ -0,0 +1,161 @@ +# Qz (1/angstrom) R sR +8.000000000000000167e-03 9.559206354062578515e-02 2.953269400266322810e-03 +9.333333333333334106e-03 1.547218526161534269e-01 4.744889095853945839e-03 +1.066666666666666631e-02 2.251218154200637367e-01 6.348290158922438933e-03 +1.200000000000000025e-02 1.733877413610561136e-01 5.333047055155121403e-03 +1.333333333333333245e-02 1.653470206046200042e-01 4.838694817206520490e-03 +1.466666666666666639e-02 1.465280571957182643e-01 4.518230715159088882e-03 +1.600000000000000033e-02 1.461178326438707653e-01 4.264358150216949334e-03 +1.733333333333333254e-02 1.361205456154175597e-01 4.036995014532264968e-03 +1.866666666666666474e-02 1.265777394925009791e-01 3.815260616836197143e-03 +2.000000000000000042e-02 1.193781936588917819e-01 3.585730713023881338e-03 +2.133333333333333262e-02 1.090994410028168771e-01 3.338564763918749900e-03 +2.266666666666666829e-02 1.035813041095600406e-01 3.066403995854868284e-03 +2.400000000000000050e-02 9.089824053785679203e-02 2.764683779577925635e-03 +2.533333333333333270e-02 7.811270518917826089e-02 2.432831941156086541e-03 +2.666666666666666491e-02 6.654117375774777632e-02 2.075818194789778599e-03 +2.800000000000000058e-02 5.713369423648240186e-02 1.705182012707161677e-03 +2.933333333333333279e-02 4.672782443053131313e-02 1.338429655907042142e-03 +3.066666666666666499e-02 3.336288338280176541e-02 9.961054459564173610e-04 +3.200000000000000067e-02 2.315392308301371729e-02 6.970987714316887645e-04 +3.333333333333333287e-02 1.526617871939014472e-02 4.540916239051049339e-04 +3.466666666666666508e-02 9.394767424312444604e-03 2.712167368097654491e-04 +3.600000000000000422e-02 4.854143272757511021e-03 1.446721420904412874e-04 +3.733333333333332948e-02 2.152754540292944711e-03 6.538873658233317738e-05 +3.866666666666666863e-02 7.634065616031009398e-04 2.216651973245304942e-05 +4.000000000000000083e-02 1.388521528749857737e-04 4.112664642903227534e-06 +4.133333333333333304e-02 6.990531242714679162e-05 2.004793178707042841e-06 +4.266666666666666524e-02 2.933666531073274254e-04 8.752884696178646077e-06 +4.399999999999999745e-02 6.199779834909185505e-04 1.930862386371532180e-05 +4.533333333333332965e-02 9.696402254148140049e-04 3.033427008241414060e-05 +4.666666666666666879e-02 1.392694326233942948e-03 3.980916777581890430e-05 +4.800000000000000100e-02 1.636276770833382027e-03 4.667475297322231428e-05 +4.933333333333333320e-02 1.675768959477367138e-03 5.054528427436338548e-05 +5.066666666666666541e-02 1.696458452372038997e-03 5.148561259012481858e-05 +5.199999999999999761e-02 1.734351324604243086e-03 4.984516191675345741e-05 +5.333333333333332982e-02 1.486767745672122306e-03 4.613524715474371079e-05 +5.466666666666666202e-02 1.327994376379723054e-03 4.093870012507678293e-05 +5.600000000000000117e-02 1.183870779060362073e-03 3.484364780409832590e-05 +5.733333333333333337e-02 9.353253204596205170e-04 2.839591481744607821e-05 +5.866666666666666557e-02 7.354355817901460021e-04 2.206645809742515158e-05 +5.999999999999999778e-02 5.383948836689723960e-04 1.623143388812569909e-05 +6.133333333333332998e-02 3.758707654323206531e-04 1.116307189423245105e-05 +6.266666666666666219e-02 2.442168725740734964e-04 7.029681693010561005e-06 +6.400000000000000133e-02 1.304549962240428025e-04 3.903043182621345985e-06 +6.533333333333332660e-02 6.018417429911210201e-05 1.771306833428140000e-06 +6.666666666666666574e-02 1.737687996777799413e-05 5.554707159785055730e-07 +6.800000000000000488e-02 4.246151878334042932e-06 1.275710080193207426e-07 +6.933333333333333015e-02 1.068694294569129868e-05 3.289291796014143113e-07 +7.066666666666665542e-02 3.170063969161577715e-05 9.871123536089979105e-07 +7.200000000000000844e-02 6.265907628402426100e-05 1.930634013376314526e-06 +7.333333333333333370e-02 9.902398487688450469e-05 3.000798661515614621e-06 +7.466666666666665897e-02 1.390667804814529310e-04 4.060434544764975005e-06 +7.599999999999998423e-02 1.600198892746388583e-04 4.999537169031307814e-06 +7.733333333333333726e-02 1.914441976505883657e-04 5.738052975700704140e-06 +7.866666666666666252e-02 2.045244694928812943e-04 6.226169716693544266e-06 +7.999999999999998779e-02 2.126408782791344753e-04 6.442557935201040233e-06 +8.133333333333334081e-02 2.194433243387519748e-04 6.391039775753465728e-06 +8.266666666666666607e-02 2.064857691278901329e-04 6.096159940720307629e-06 +8.399999999999999134e-02 1.940905476443417473e-04 5.598109401755303021e-06 +8.533333333333334436e-02 1.641477730085187799e-04 4.947365070275969244e-06 +8.666666666666666963e-02 1.370677240676891823e-04 4.199714530999940796e-06 +8.799999999999999489e-02 1.129414682318471226e-04 3.411152544137297965e-06 +8.933333333333332016e-02 8.843929941758425740e-05 2.634016764123887818e-06 +9.066666666666667318e-02 6.412665746891291934e-05 1.913662668736399103e-06 +9.199999999999999845e-02 4.147629179578066075e-05 1.286128630253563915e-06 +9.333333333333332371e-02 2.596232947739787901e-05 7.767612157382616766e-07 +9.466666666666667673e-02 1.341676511753195322e-05 3.997658169802778493e-07 +9.600000000000000200e-02 5.685655807735410875e-06 1.585921273381317201e-07 +9.733333333333332726e-02 1.655584386986792729e-06 4.702005169047989737e-08 +9.866666666666665253e-02 1.649387598957539986e-06 5.078149721359380304e-08 +1.000000000000000056e-01 4.941626584690286240e-06 1.495380398780978620e-07 +1.013333333333333308e-01 1.016755565950663672e-05 3.190332704228036644e-07 +1.026666666666666561e-01 1.746000858802738620e-05 5.332500947762522907e-07 +1.040000000000000091e-01 2.578938495532255237e-05 7.664249224476520064e-07 +1.053333333333333344e-01 3.435956766836428144e-05 9.947995508378539123e-07 +1.066666666666666596e-01 3.906069149004365998e-05 1.198024554907398794e-06 +1.079999999999999849e-01 4.444899573477161902e-05 1.360162230472496274e-06 +1.093333333333333379e-01 4.585190983361416169e-05 1.470270143505472138e-06 +1.106666666666666632e-01 5.050486761681483483e-05 1.522576166114266420e-06 +1.119999999999999885e-01 4.893178590785611683e-05 1.516281145477262508e-06 +1.133333333333333415e-01 4.773114780652696064e-05 1.455045182847769273e-06 +1.146666666666666667e-01 4.369379483838258795e-05 1.346227467082781299e-06 +1.159999999999999920e-01 3.988547717775933039e-05 1.199957647518278861e-06 +1.173333333333333173e-01 3.246348183446290359e-05 1.028119077817123801e-06 +1.186666666666666703e-01 2.687352247034604499e-05 8.433213933226984112e-07 +1.199999999999999956e-01 2.333198357392108141e-05 6.579324820953014434e-07 +1.213333333333333208e-01 1.548550790498974200e-05 4.832288283145338830e-07 +1.226666666666666738e-01 1.059645740899342626e-05 3.287094353144484335e-07 +1.239999999999999991e-01 7.090432641232453841e-06 2.016031524920286319e-07 +1.253333333333333244e-01 3.862409946375891984e-06 1.065833450927718027e-07 +1.266666666666666496e-01 1.469423884753160196e-06 4.568853130034043198e-08 +1.280000000000000027e-01 6.087710229395623162e-07 1.846714590461365715e-08 +1.293333333333333279e-01 7.448799100754689694e-07 2.211974366523678340e-08 +1.306666666666666532e-01 1.826772739081254099e-06 5.210116722106263709e-08 +1.320000000000000062e-01 3.315054684218319539e-06 1.024858058137203220e-07 +1.333333333333333315e-01 5.510417021493823192e-06 1.665379527543908528e-07 +1.346666666666666567e-01 8.092951550599856943e-06 2.372557130903813883e-07 +1.360000000000000098e-01 1.039657876854264040e-05 3.078816679312777193e-07 +1.373333333333333350e-01 1.227155477246007717e-05 3.723484773852547281e-07 +1.386666666666666603e-01 1.413087682725235949e-05 4.256350973333876091e-07 +1.400000000000000133e-01 1.482927979940897518e-05 4.640176725731831399e-07 +1.413333333333333386e-01 1.605802964228135723e-05 4.852078020329175624e-07 +1.426666666666666639e-01 1.614920717614244647e-05 4.883791582282668553e-07 +1.439999999999999891e-01 1.591309885259072651e-05 4.740908767816653833e-07 +1.453333333333333421e-01 1.455744390598313682e-05 4.441223134538681608e-07 +1.466666666666666674e-01 1.356381319299208061e-05 4.012384147119141214e-07 +1.479999999999999927e-01 1.198360802068795317e-05 3.489079040197438474e-07 +1.493333333333333457e-01 9.745153220819537010e-06 2.909977092754138739e-07 +1.506666666666666710e-01 7.796973777661658211e-06 2.314666173503456162e-07 +1.519999999999999962e-01 5.811893499401644837e-06 1.740792077582153121e-07 +1.533333333333333492e-01 4.071941595569973699e-06 1.221579385885168385e-07 +1.546666666666666745e-01 2.556343939745368904e-06 7.838714440552985338e-08 +1.559999999999999998e-01 1.503407203321582893e-06 4.467800612282114720e-08 +1.573333333333333250e-01 7.344710467753042064e-07 2.209862841490180983e-08 +1.586666666666666781e-01 3.850350951639976028e-07 1.086856102959512961e-08 +1.600000000000000033e-01 3.634743364778997648e-07 1.041274193708913005e-08 +1.613333333333333286e-01 6.563837631172683734e-07 1.946618409280883705e-08 +1.626666666666666816e-01 1.179629173274889455e-06 3.621796661461978179e-08 +1.640000000000000069e-01 1.884322723145190400e-06 5.848134113771273303e-08 +1.653333333333333321e-01 2.895777093918394305e-06 8.387606143231335982e-08 +1.666666666666666574e-01 3.695883207410113677e-06 1.100093500775489120e-07 +1.680000000000000104e-01 4.552752173050893864e-06 1.346431249439112783e-07 +1.693333333333333357e-01 4.922451901935751810e-06 1.558292846488293385e-07 +1.706666666666666610e-01 5.894527448035536099e-06 1.720488808566031183e-07 +1.720000000000000140e-01 6.157767985265139900e-06 1.822485165816314762e-07 +1.733333333333333393e-01 5.990126113411925469e-06 1.858965400689284567e-07 +1.746666666666666645e-01 6.012867268531592707e-06 1.829743260720296958e-07 +1.759999999999999898e-01 5.843769408504052207e-06 1.739369770554231395e-07 +1.773333333333333428e-01 5.329851047718660423e-06 1.596442507501745216e-07 +1.786666666666666681e-01 4.667685170899017475e-06 1.412687952699019301e-07 +1.799999999999999933e-01 3.993887724572344332e-06 1.201897786982717883e-07 +1.813333333333333464e-01 3.238015637652685508e-06 9.788037836311563450e-08 +1.826666666666666716e-01 2.538142772321931427e-06 7.579736806318370658e-08 +1.839999999999999969e-01 1.924020142961066166e-06 5.528027007827952529e-08 +1.853333333333333222e-01 1.152713951720483779e-06 3.746631560777311627e-08 +1.866666666666666752e-01 7.686955653751071116e-07 2.322589876926246126e-08 +1.880000000000000004e-01 4.396976389188759605e-07 1.312144622023899743e-08 +1.893333333333333257e-01 2.485473702509119916e-07 7.390792205372861249e-09 +1.906666666666666787e-01 1.962647411445939018e-07 5.954377832495861077e-09 +1.920000000000000040e-01 2.666329261163428065e-07 8.444000575771889354e-09 +1.933333333333333293e-01 4.796578346343829695e-07 1.424952167638328705e-08 +1.946666666666666545e-01 7.916339700247115016e-07 2.257896566540885249e-08 +1.960000000000000075e-01 1.034336944868405880e-06 3.252685916284300600e-08 +1.973333333333333328e-01 1.475452503850628252e-06 4.314546721810594161e-08 +1.986666666666666581e-01 1.766211982801583463e-06 5.351377826236124305e-08 +2.000000000000000111e-01 2.089469578857149948e-06 6.279962173735476181e-08 +2.013333333333333364e-01 2.269673329716914896e-06 7.031111390500284955e-08 +2.026666666666666616e-01 2.492558341063638476e-06 7.553464103958034328e-08 +2.039999999999999869e-01 2.706865884171947466e-06 7.815772084942968741e-08 +2.053333333333333399e-01 2.648033083936830869e-06 7.807624648935255560e-08 +2.066666666666666652e-01 2.643484343093922649e-06 7.538673102154163830e-08 +2.079999999999999905e-01 2.428354149668647728e-06 7.036516117011372644e-08 +2.093333333333333435e-01 2.142349369207815779e-06 6.343487876882734094e-08 +2.106666666666666687e-01 1.933686797324920648e-06 5.512649403226417192e-08 +2.119999999999999940e-01 1.554627038754858763e-06 4.603257164174999102e-08 +2.133333333333333470e-01 1.256091035750277766e-06 3.676939240707620845e-08 +2.146666666666666723e-01 9.233558669538192546e-07 2.794934492486571105e-08 +2.159999999999999976e-01 6.755529096878243186e-07 2.022620820483286770e-08 +2.173333333333333228e-01 4.645958262495849456e-07 1.423572479592631334e-08 +2.186666666666666758e-01 3.503492403170606910e-07 1.020744354514503706e-08 +2.200000000000000011e-01 2.500273052907539463e-07 7.775682608413152450e-09 diff --git a/notebooks/polarized_demo_data/fe_on_si_uu.dat b/notebooks/polarized_demo_data/fe_on_si_uu.dat new file mode 100644 index 00000000..3694bfb7 --- /dev/null +++ b/notebooks/polarized_demo_data/fe_on_si_uu.dat @@ -0,0 +1,161 @@ +# Qz (1/angstrom) R sR +8.000000000000000167e-03 9.097992872869360426e-01 2.704673059568740018e-02 +9.333333333333334106e-03 8.063090430971723288e-01 2.496826939277983076e-02 +1.066666666666666631e-02 3.993410666786681995e-01 1.171645321068302131e-02 +1.200000000000000025e-02 2.208085349552587062e-01 6.442469270198266686e-03 +1.333333333333333245e-02 1.378701987041681676e-01 4.393247367307369713e-03 +1.466666666666666639e-02 1.054462848413706411e-01 3.291991459709087391e-03 +1.600000000000000033e-02 8.744568904510718887e-02 2.613347927772132363e-03 +1.733333333333333254e-02 7.146167917255358271e-02 2.164384492054474089e-03 +1.866666666666666474e-02 6.167976374855279470e-02 1.851326045070834578e-03 +2.000000000000000042e-02 5.265218748124480402e-02 1.621050441499697877e-03 +2.133333333333333262e-02 4.927121530985689724e-02 1.440142702025466563e-03 +2.266666666666666829e-02 4.386922365138993390e-02 1.286067912029972274e-03 +2.400000000000000050e-02 3.818426600138049437e-02 1.143263265913084135e-03 +2.533333333333333270e-02 3.451548358949688838e-02 1.001593387513934411e-03 +2.666666666666666491e-02 2.893314831493903547e-02 8.559889643176760762e-04 +2.800000000000000058e-02 2.294049681346108441e-02 7.064256926010059525e-04 +2.933333333333333279e-02 1.878720553741572083e-02 5.574493693613232197e-04 +3.066666666666666499e-02 1.348984789683120925e-02 4.166819078504779708e-04 +3.200000000000000067e-02 1.000274871208850849e-02 2.923772937100064802e-04 +3.333333333333333287e-02 6.352269739014006905e-03 1.908539488883274199e-04 +3.466666666666666508e-02 3.807788233433779135e-03 1.148707051044449136e-04 +3.600000000000000422e-02 2.072462443473301660e-03 6.347044024293114052e-05 +3.733333333333332948e-02 1.139804540655666408e-03 3.298439271106011055e-05 +3.866666666666666863e-02 6.122598816790504953e-04 1.845334402119248518e-05 +4.000000000000000083e-02 4.883437676771125490e-04 1.484101765341856499e-05 +4.133333333333333304e-02 5.863780344992955938e-04 1.777916019914761169e-05 +4.266666666666666524e-02 8.085021003458838429e-04 2.387381547227360713e-05 +4.399999999999999745e-02 1.035202554519308550e-03 3.071929115713385912e-05 +4.533333333333332965e-02 1.240744044437433122e-03 3.676707224072342880e-05 +4.666666666666666879e-02 1.389209491691400242e-03 4.114450721474634341e-05 +4.800000000000000100e-02 1.542409592178596968e-03 4.347879975014890382e-05 +4.933333333333333320e-02 1.440437539146308685e-03 4.374650324943592949e-05 +5.066666666666666541e-02 1.383520769361035446e-03 4.215340635820877485e-05 +5.199999999999999761e-02 1.269632015624745126e-03 3.904210241469862578e-05 +5.333333333333332982e-02 1.182193676818206300e-03 3.482231541233326162e-05 +5.466666666666666202e-02 1.031084451500815938e-03 2.991919527059426961e-05 +5.600000000000000117e-02 8.217067419050427812e-04 2.473575956461779602e-05 +5.733333333333333337e-02 6.377339302697241285e-04 1.962670295639886505e-05 +5.866666666666666557e-02 4.837841769535204824e-04 1.488161363565887598e-05 +5.999999999999999778e-02 3.641768060699149896e-04 1.071614871017455281e-05 +6.133333333333332998e-02 2.477354188367224784e-04 7.269959728324917905e-06 +6.266666666666666219e-02 1.561776233511654095e-04 4.610207090761423770e-06 +6.400000000000000133e-02 8.949197401578911281e-05 2.739453201419914626e-06 +6.533333333333332660e-02 5.392847547534247797e-05 1.606664106320856537e-06 +6.666666666666666574e-02 3.746532653419778743e-05 1.120039016259490813e-06 +6.800000000000000488e-02 3.893568137509112090e-05 1.160457079345345069e-06 +6.933333333333333015e-02 5.453891258501781982e-05 1.594483025713826729e-06 +7.066666666666665542e-02 7.671411705147658197e-05 2.286088733596215601e-06 +7.200000000000000844e-02 1.056589203629337842e-04 3.106496336003633004e-06 +7.333333333333333370e-02 1.316596805769652617e-04 3.941798924206651489e-06 +7.466666666666665897e-02 1.579664076472516462e-04 4.698241645434700220e-06 +7.599999999999998423e-02 1.801902185616797894e-04 5.305232630010590067e-06 +7.733333333333333726e-02 1.822135625501344306e-04 5.716292754145070896e-06 +7.866666666666666252e-02 1.950528509916157188e-04 5.908246419330654096e-06 +7.999999999999998779e-02 1.932016051436307032e-04 5.879007890693535743e-06 +8.133333333333334081e-02 1.845385281838792435e-04 5.644337103825948520e-06 +8.266666666666666607e-02 1.730243643665857608e-04 5.233933216032450874e-06 +8.399999999999999134e-02 1.632474535192117589e-04 4.687210468655069082e-06 +8.533333333333334436e-02 1.314622827741316344e-04 4.049042081922340294e-06 +8.666666666666666963e-02 1.154554884671698140e-04 3.365891070005132427e-06 +8.799999999999999489e-02 8.488985829504271097e-05 2.682104738513855658e-06 +8.933333333333332016e-02 6.722305804620891541e-05 2.037158154475688395e-06 +9.066666666666667318e-02 4.901979405596596531e-05 1.463448400295844109e-06 +9.199999999999999845e-02 3.340711008125167076e-05 9.848923261699929418e-07 +9.333333333333332371e-02 2.098114612862189604e-05 6.162848391097110831e-07 +9.466666666666667673e-02 1.240039510890430205e-05 3.633636469272716737e-07 +9.600000000000000200e-02 7.371654825392529470e-06 2.234877178786924464e-07 +9.733333333333332726e-02 6.140559884840828304e-06 1.868079259283322616e-07 +9.866666666666665253e-02 8.130369333425144199e-06 2.377905239719707620e-07 +1.000000000000000056e-01 1.182996255883061676e-05 3.569474444613453580e-07 +1.013333333333333308e-01 1.675432993250500145e-05 5.226313062615320515e-07 +1.026666666666666561e-01 2.295109736382246668e-05 7.127659779979260574e-07 +1.040000000000000091e-01 2.938005921282587575e-05 9.064036228776255802e-07 +1.053333333333333344e-01 3.670689922491348158e-05 1.085024036035259375e-06 +1.066666666666666596e-01 4.129299961414677103e-05 1.233519440994174777e-06 +1.079999999999999849e-01 4.562034593524948124e-05 1.340835558594794967e-06 +1.093333333333333379e-01 4.607726060783903278e-05 1.400265836765147427e-06 +1.106666666666666632e-01 4.720407447129864551e-05 1.409418769648823861e-06 +1.119999999999999885e-01 4.652023459077186561e-05 1.369897201817758128e-06 +1.133333333333333415e-01 4.249337645689370844e-05 1.286742776469133279e-06 +1.146666666666666667e-01 3.945697934704982322e-05 1.167707977743549753e-06 +1.159999999999999920e-01 3.340398457858895855e-05 1.022422578174446118e-06 +1.173333333333333173e-01 2.840458916012842668e-05 8.615210305247138080e-07 +1.186666666666666703e-01 2.292748762504616899e-05 6.957929447530528394e-07 +1.199999999999999956e-01 1.720676560194368978e-05 5.354109368057387761e-07 +1.213333333333333208e-01 1.316555588048205136e-05 3.892796225096771491e-07 +1.226666666666666738e-01 8.693733480536175017e-06 2.645372363504957658e-07 +1.239999999999999991e-01 5.543016807589842190e-06 1.662281979824507759e-07 +1.253333333333333244e-01 3.285099840968646500e-06 9.715183266031837250e-08 +1.266666666666666496e-01 1.955185778289120519e-06 5.788021375121773621e-08 +1.280000000000000027e-01 1.596394802918315076e-06 4.695455817334131952e-08 +1.293333333333333279e-01 2.029656599321841208e-06 6.107013363064531437e-08 +1.306666666666666532e-01 3.147121394412769539e-06 9.562801718007675268e-08 +1.320000000000000062e-01 4.824223022243863525e-06 1.450736410006232978e-07 +1.333333333333333315e-01 6.436506611609275528e-06 2.033908498851756146e-07 +1.346666666666666567e-01 8.435753307531895936e-06 2.645579526131113161e-07 +1.360000000000000098e-01 1.033821735151448561e-05 3.229619707509598506e-07 +1.373333333333333350e-01 1.208550851253621602e-05 3.737467893770618719e-07 +1.386666666666666603e-01 1.393439253998442856e-05 4.130776423481388130e-07 +1.400000000000000133e-01 1.421350564839384544e-05 4.383116294576547520e-07 +1.413333333333333386e-01 1.476626395892329756e-05 4.480712316077902185e-07 +1.426666666666666639e-01 1.531537018309152517e-05 4.422245846616717551e-07 +1.439999999999999891e-01 1.390914065522243100e-05 4.217821936334787589e-07 +1.453333333333333421e-01 1.324417411875621121e-05 3.887245121754610337e-07 +1.466666666666666674e-01 1.120311462801779919e-05 3.457781774349806621e-07 +1.479999999999999927e-01 9.811176893421107628e-06 2.961605820037383467e-07 +1.493333333333333457e-01 7.879277007894731297e-06 2.433128885477734744e-07 +1.506666666666666710e-01 6.290054724570724669e-06 1.906406462781018071e-07 +1.519999999999999962e-01 4.828018842308375617e-06 1.412790277686688167e-07 +1.533333333333333492e-01 3.094121452750714761e-06 9.789660788748038203e-08 +1.546666666666666745e-01 2.112100091210456856e-06 6.254783498520832551e-08 +1.559999999999999998e-01 1.228036474086856133e-06 3.658020173382685675e-08 +1.573333333333333250e-01 6.743588996379143178e-07 2.059791448856219756e-08 +1.586666666666666781e-01 4.617236887443765433e-07 1.447987267050306567e-08 +1.600000000000000033e-01 5.828001903164824309e-07 1.744625401808121305e-08 +1.613333333333333286e-01 9.239253352866243629e-07 2.816515742376424879e-08 +1.626666666666666816e-01 1.506735250009409562e-06 4.488872137164296450e-08 +1.640000000000000069e-01 2.188333174891102094e-06 6.560698564480311472e-08 +1.653333333333333321e-01 3.081539047557577154e-06 8.820750389297125160e-08 +1.666666666666666574e-01 3.661163273165275461e-06 1.106292904922382127e-07 +1.680000000000000104e-01 4.232620483364079970e-06 1.310009900462210656e-07 +1.693333333333333357e-01 4.951403813595259923e-06 1.477474886625623296e-07 +1.706666666666666610e-01 5.358535328271501864e-06 1.597020422565462717e-07 +1.720000000000000140e-01 5.763097368670237389e-06 1.661193023089299000e-07 +1.733333333333333393e-01 5.696373066655557468e-06 1.667144389158185545e-07 +1.746666666666666645e-01 5.445754992892647173e-06 1.616420882892517069e-07 +1.759999999999999898e-01 5.269992715381009275e-06 1.514512115875856919e-07 +1.773333333333333428e-01 4.404327904642388854e-06 1.370162321789783880e-07 +1.786666666666666681e-01 3.905281055242455666e-06 1.194510004765750695e-07 +1.799999999999999933e-01 3.241090765491239341e-06 1.000128067826545866e-07 +1.813333333333333464e-01 2.635606843178464082e-06 8.000379318150786771e-08 +1.826666666666666716e-01 1.939025085671843059e-06 6.067673728096377354e-08 +1.839999999999999969e-01 1.465786188839188880e-06 4.315135690925841093e-08 +1.853333333333333222e-01 9.385709943918117380e-07 2.834610426400807751e-08 +1.866666666666666752e-01 5.394002925749825609e-07 1.692898657779461566e-08 +1.880000000000000004e-01 3.002120132500959102e-07 9.289383504435447816e-09 +1.893333333333333257e-01 1.861089710946391928e-07 5.531245471371540921e-09 +1.906666666666666787e-01 1.874838176711437345e-07 5.486561540334066774e-09 +1.920000000000000040e-01 3.090206001193436630e-07 8.746675300421817014e-09 +1.933333333333333293e-01 5.331229924230900979e-07 1.470797893923666382e-08 +1.946666666666666545e-01 7.636365505684721927e-07 2.262778153340758893e-08 +1.960000000000000075e-01 1.024837574750060220e-06 3.168575511907128065e-08 +1.973333333333333328e-01 1.280697801903912735e-06 4.104631324165549513e-08 +1.986666666666666581e-01 1.677281826504772928e-06 4.991754978763511508e-08 +2.000000000000000111e-01 1.873269516152690271e-06 5.760291888369212628e-08 +2.013333333333333364e-01 2.091694161306209310e-06 6.354261138831364574e-08 +2.026666666666666616e-01 2.203530231180347108e-06 6.734251099808780579e-08 +2.039999999999999869e-01 2.283302344461021660e-06 6.878961887636610838e-08 +2.053333333333333399e-01 2.334125714454877571e-06 6.785384569096191238e-08 +2.066666666666666652e-01 2.166057926540760182e-06 6.467701482049679655e-08 +2.079999999999999905e-01 1.975577859553661420e-06 5.955074044679917747e-08 +2.093333333333333435e-01 1.708078682226702826e-06 5.288549222147748138e-08 +2.106666666666666687e-01 1.430135275616249324e-06 4.517360209801992675e-08 +2.119999999999999940e-01 1.213667744507251196e-06 3.694909139659465115e-08 +2.133333333333333470e-01 9.568654434433844830e-07 2.875235455239735603e-08 +2.146666666666666723e-01 7.408982845384479317e-07 2.110745357871421160e-08 +2.159999999999999976e-01 4.869702574935956964e-07 1.455223416454345105e-08 +2.173333333333333228e-01 3.285896769109876361e-07 9.575386973950528065e-09 +2.186666666666666758e-01 2.068206441979765097e-07 6.298970777474893842e-09 +2.200000000000000011e-01 1.401986602342771183e-07 4.360985483968211197e-09 diff --git a/notebooks/polarized_fitting.ipynb b/notebooks/polarized_fitting.ipynb new file mode 100644 index 00000000..83c4cedc --- /dev/null +++ b/notebooks/polarized_fitting.ipynb @@ -0,0 +1,507 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro-title", + "metadata": {}, + "source": [ + "# Polarized Neutron Reflectometry — Magnetic Models and Simultaneous Multi-Channel Fitting\n", + "\n", + "This notebook demonstrates the polarized (PNR) functionality of `easyreflectometry`:\n", + "\n", + "| Feature | API |\n", + "|---|---|\n", + "| Magnetic layers as first-class model parameters | `LayerMagnetism(rho_m, theta_m)` on `Layer` |\n", + "| All four spin channels in one calculation | `interface.polarized_reflectivity_profiles(q, model_id)` |\n", + "| Nuclear + magnetic SLD profile | `interface.magnetic_sld_profile(model_id)` |\n", + "| Per-file spin-channel detection | `detect_polarization_channel(path)` |\n", + "| One experiment = one dataset per channel | `PolarizedDataSet` |\n", + "| Simultaneous fit of all measured channels | `MultiFitter.fit_polarized(data)` |\n", + "\n", + "The magnetic parameters `rho_m` (magnetic SLD, in 10⁻⁶ Å⁻²) and `theta_m` (in-plane moment\n", + "angle, degrees) are ordinary `easyscience` `Parameter`s: they can be fixed or freed, bounded,\n", + "serialized, and fitted: together with the structural parameters, against **all measured spin\n", + "channels at once**. The refl1d backend computes the four spin cross-sections in a single kernel\n", + "evaluation and caches them per iteration, so an N-channel fit costs about as much as a\n", + "single-channel one.\n", + "\n", + "**Convention** (refl1d): with the default guide field (`Aguide = 270°`), a moment at\n", + "`theta_m = 270°` is *aligned* with the field: the non-spin-flip channels see\n", + "ρ ± ρ_M and the spin-flip channels vanish. Any other angle cants the moment and\n", + "produces spin-flip scattering. Polarized calculations require the **refl1d** calculator." + ] + }, + { + "cell_type": "markdown", + "id": "imports-md", + "metadata": {}, + "source": [ + "## 1. Imports" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "imports-code", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from easyreflectometry.calculators import CalculatorFactory\n", + "from easyreflectometry.data import PolarizedDataSet\n", + "from easyreflectometry.data import detect_polarization_channel\n", + "from easyreflectometry.data import load_as_dataset\n", + "from easyreflectometry.fitting import MultiFitter\n", + "from easyreflectometry.model import Model\n", + "from easyreflectometry.model import PercentageFwhm\n", + "from easyreflectometry.sample import Layer\n", + "from easyreflectometry.sample import LayerMagnetism\n", + "from easyreflectometry.sample import Material\n", + "from easyreflectometry.sample import Multilayer\n", + "from easyreflectometry.sample import Sample\n", + "\n", + "%matplotlib inline\n", + "\n", + "rng = np.random.default_rng(42)\n", + "\n", + "CHANNEL_LABELS = {'pp': 'R++ (up-up)', 'pm': 'R+- (up-down)', 'mp': 'R-+ (down-up)', 'mm': 'R-- (down-down)'}\n", + "CHANNEL_COLORS = {'pp': 'C0', 'pm': 'C2', 'mp': 'C3', 'mm': 'C1'}" + ] + }, + { + "cell_type": "markdown", + "id": "sample-md", + "metadata": {}, + "source": [ + "## 2. Build a Magnetic Sample\n", + "\n", + "A single ferromagnetic iron film on silicon, measured in vacuum:\n", + "\n", + "- **Vacuum** superphase\n", + "- **Fe film**, 200 Å: nuclear SLD 8.02·10⁻⁶ Å⁻², magnetic SLD `rho_m = 5.0`·10⁻⁶ Å⁻²\n", + " (bulk Fe), moment canted at `theta_m = 40°` so that all four channels are non-trivial\n", + "- **Si** substrate\n", + "\n", + "Attaching a `LayerMagnetism` to a layer is all that is needed: when the model is given a\n", + "calculator interface, magnetism is switched on automatically (and removing the last magnetic\n", + "layer switches it off again)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sample-code", + "metadata": {}, + "outputs": [], + "source": [ + "TRUTH = {'thickness': 200.0, 'rho_m': 5.0, 'theta_m': 40.0}\n", + "\n", + "\n", + "def build_model(thickness=TRUTH['thickness'], rho_m=TRUTH['rho_m'], theta_m=TRUTH['theta_m'], name='PNR Model'):\n", + " \"\"\"Vacuum | Fe film (magnetic) | Si substrate, with a fresh refl1d calculator.\"\"\"\n", + " vacuum = Material(0.0, 0.0, 'Vacuum')\n", + " iron = Material(8.02, 0.0, 'Fe')\n", + " silicon = Material(2.07, 0.0, 'Si')\n", + "\n", + " superphase = Layer(vacuum, 0, 0, 'Vacuum superphase')\n", + " film = Layer(\n", + " iron, thickness, 5, 'Fe film',\n", + " magnetism=LayerMagnetism(rho_m=rho_m, theta_m=theta_m, name='Fe moment'),\n", + " )\n", + " substrate = Layer(silicon, 0, 3, 'Si substrate')\n", + "\n", + " sample = Sample(Multilayer(superphase), Multilayer(film), Multilayer(substrate), name='Fe on Si')\n", + " model = Model(sample, 1.0, 0.0, PercentageFwhm(2.0), name)\n", + "\n", + " interface = CalculatorFactory()\n", + " interface.switch('refl1d') # magnetism requires the refl1d backend\n", + " model.interface = interface\n", + " return model\n", + "\n", + "\n", + "truth_model = build_model()\n", + "print(truth_model)\n", + "print(f'has_magnetism : {truth_model.has_magnetism}')\n", + "print(f'calculator magnetism flag: {truth_model.interface().include_magnetism} (enabled automatically)')" + ] + }, + { + "cell_type": "markdown", + "id": "simulate-md", + "metadata": {}, + "source": [ + "## 3. Simulate All Four Spin Channels\n", + "\n", + "`polarized_reflectivity_profiles` returns a dictionary keyed `'pp'`, `'pm'`, `'mp'`, `'mm'`.\n", + "All four cross-sections come from one refl1d kernel evaluation.\n", + "\n", + "With the moment canted at 40° the non-spin-flip channels split (they see the moment's\n", + "projection on the field) and the spin-flip channels pick up the perpendicular component.\n", + "For a non-chiral, non-absorptive sample `pm` and `mp` coincide by symmetry." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "simulate-code", + "metadata": {}, + "outputs": [], + "source": [ + "q = np.linspace(0.008, 0.22, 160)\n", + "channels_truth = truth_model.interface.polarized_reflectivity_profiles(q, truth_model.unique_name)\n", + "\n", + "fig, ax = plt.subplots(figsize=(10, 6))\n", + "for channel, reflectivity in channels_truth.items():\n", + " ax.plot(q, reflectivity, color=CHANNEL_COLORS[channel], lw=1.5, label=CHANNEL_LABELS[channel])\n", + "ax.set_yscale('log')\n", + "ax.set_xlabel('Q (Å⁻¹)')\n", + "ax.set_ylabel('Reflectivity')\n", + "ax.set_title('Simulated spin channels — Fe film, moment canted 40°')\n", + "ax.legend()\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "sld-md", + "metadata": {}, + "source": [ + "## 4. Nuclear and Magnetic SLD Profile\n", + "\n", + "`magnetic_sld_profile` returns `z`, nuclear ρ(z), magnetic ρ_M(z) and the moment angle θ_M(z).\n", + "The most intuitive PNR view adds the **spin-dependent potentials**: what each neutron spin\n", + "state actually \"sees\":\n", + "\n", + "$$\\rho_\\pm(z) = \\rho(z) \\pm \\rho_M(z)\\,\\cos\\bigl(\\theta_M(z) - A_\\text{guide}\\bigr)$$" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sld-code", + "metadata": {}, + "outputs": [], + "source": [ + "z, sld, rho_m_profile, theta_m_profile = truth_model.interface.magnetic_sld_profile(truth_model.unique_name)\n", + "\n", + "AGUIDE = 270.0 # refl1d default guide-field angle\n", + "projection = rho_m_profile * np.cos(np.radians(theta_m_profile - AGUIDE))\n", + "\n", + "fig, ax = plt.subplots(figsize=(10, 6))\n", + "ax.plot(z, sld, 'k-', lw=2, label='nuclear ρ(z)')\n", + "ax.plot(z, rho_m_profile, 'C4-', lw=2, label='magnetic ρ$_M$(z)')\n", + "ax.plot(z, sld + projection, 'C0--', lw=1.5, label='spin-up potential ρ + ρ$_M$cos(θ$_M$−A)')\n", + "ax.plot(z, sld - projection, 'C1--', lw=1.5, label='spin-down potential ρ − ρ$_M$cos(θ$_M$−A)')\n", + "ax.set_xlabel('z (Å)')\n", + "ax.set_ylabel('SLD (10⁻⁶ Å⁻²)')\n", + "ax.set_title('Nuclear and magnetic SLD profile')\n", + "ax.legend(loc='upper right', fontsize=9)\n", + "\n", + "ax2 = ax.twinx()\n", + "ax2.plot(z, theta_m_profile, 'C7:', lw=1.5)\n", + "ax2.set_ylabel('θ$_M$ (deg)', color='C7')\n", + "ax2.tick_params(axis='y', colors='C7')\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "sa-md", + "metadata": {}, + "source": [ + "## 5. Spin Asymmetry\n", + "\n", + "The spin asymmetry\n", + "\n", + "$$SA = \\frac{R^{++} - R^{--}}{R^{++} + R^{--}}$$\n", + "\n", + "removes most of the structural (nuclear) contribution and is visually far more sensitive to\n", + "weak magnetism than the raw reflectivities: the standard first look at any PNR measurement." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sa-code", + "metadata": {}, + "outputs": [], + "source": [ + "spin_asymmetry = (channels_truth['pp'] - channels_truth['mm']) / (channels_truth['pp'] + channels_truth['mm'])\n", + "\n", + "fig, ax = plt.subplots(figsize=(10, 4))\n", + "ax.plot(q, spin_asymmetry, 'C5-', lw=1.5)\n", + "ax.axhline(0, color='k', lw=0.5)\n", + "ax.set_xlabel('Q (Å⁻¹)')\n", + "ax.set_ylabel('(R⁺⁺ − R⁻⁻) / (R⁺⁺ + R⁻⁻)')\n", + "ax.set_title('Spin asymmetry')\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "files-md", + "metadata": {}, + "source": [ + "## 6. A Synthetic Experiment: One File per Channel\n", + "\n", + "Polarized measurements typically arrive as **one file per spin channel**. We simulate that:\n", + "3% relative noise on each channel, written to four separate files whose names carry the\n", + "conventional channel suffixes (`_uu`, `_dd`, `_ud`, `_du`).\n", + "\n", + "`detect_polarization_channel` identifies the channel of each file: from the ORSO header\n", + "(`instrument_settings.polarization`) when present, otherwise from filename tokens. Only the\n", + "four fully-analysed cross-sections `pp`/`pm`/`mp`/`mm` are ever assigned; partially-analysed\n", + "observables (`po`, `mo`, …) measure *channel sums* and are left for the user to decide.\n", + "(For GUI workflows, `Project.suggest_polarized_channel_assignment(paths)` wraps this per-file\n", + "and `Project.load_polarized_experiment({channel: path})` performs the load.)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "files-code", + "metadata": {}, + "outputs": [], + "source": [ + "DATA_DIR = 'polarized_demo_data'\n", + "os.makedirs(DATA_DIR, exist_ok=True)\n", + "\n", + "FILE_SUFFIX = {'pp': 'uu', 'pm': 'ud', 'mp': 'du', 'mm': 'dd'}\n", + "NOISE = 0.03\n", + "\n", + "file_paths = []\n", + "for channel, reflectivity in channels_truth.items():\n", + " sigma = NOISE * reflectivity\n", + " noisy = np.clip(reflectivity + sigma * rng.standard_normal(len(q)), 1e-12, None)\n", + " path = os.path.join(DATA_DIR, f'fe_on_si_{FILE_SUFFIX[channel]}.dat')\n", + " np.savetxt(path, np.column_stack([q, noisy, sigma]), header='Qz (1/angstrom) R sR')\n", + " file_paths.append(path)\n", + "\n", + "print('Automatic channel detection:')\n", + "for path in file_paths:\n", + " detected = detect_polarization_channel(path)\n", + " print(f' {os.path.basename(path):24s} -> {detected.value if detected else \"(user must assign)\"}')" + ] + }, + { + "cell_type": "markdown", + "id": "dataset-md", + "metadata": {}, + "source": [ + "## 7. Group the Channels into a `PolarizedDataSet`\n", + "\n", + "A `PolarizedDataSet` holds one `DataSet1D` per measured channel (any subset of the four -\n", + "an NSF-only experiment would just have `pp` and `mm`) and one **shared model**. Channels are\n", + "kept in canonical order (pp, pm, mp, mm); the `channels` mapping is read-only, with validated\n", + "`set_channel` / `remove_channel` methods for editing.\n", + "\n", + "> **Note** — `DataSet1D.ye` stores **variances** (σ²), following the scipp convention. The\n", + "> text-file loader squares the error column for you.\n", + "\n", + "We start the fit model from deliberately wrong values: thickness 180 Å (truth 200),\n", + "`rho_m` 3.0 (truth 5.0), `theta_m` 60° (truth 40°)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dataset-code", + "metadata": {}, + "outputs": [], + "source": [ + "fit_model = build_model(thickness=180.0, rho_m=3.0, theta_m=60.0, name='PNR Fit Model')\n", + "\n", + "data = PolarizedDataSet(\n", + " name='Fe on Si (synthetic PNR)',\n", + " channels={detect_polarization_channel(path): load_as_dataset(path) for path in file_paths},\n", + " model=fit_model,\n", + ")\n", + "print(data)\n", + "print(f'channels: {[channel.value for channel in data.available_channels]}')\n", + "print(f\"points per channel: {len(data['pp'].x)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "fit-md", + "metadata": {}, + "source": [ + "## 8. Simultaneous Multi-Channel Fit\n", + "\n", + "`MultiFitter.fit_polarized(data)` fits **all measured channels at once** against the one\n", + "shared model:\n", + "\n", + "- structural parameters (thickness, roughness, nuclear SLD, scale, background) are common to\n", + " every channel automatically;\n", + "- the magnetic parameters shape the channels through the spin-dependent kernel: the\n", + " non-spin-flip splitting pins `rho_m·cos θ_m` while the spin-flip channels pin the\n", + " perpendicular component, so `rho_m` and `theta_m` are individually well-determined;\n", + "- each iteration costs a single refl1d kernel evaluation thanks to the four-channel cache.\n", + "\n", + "It returns one `FitResults` per channel." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fit-code", + "metadata": {}, + "outputs": [], + "source": [ + "film_layer = fit_model.sample[1].layers[0]\n", + "\n", + "film_layer.thickness.fixed = False\n", + "film_layer.thickness.bounds = (150, 250)\n", + "film_layer.magnetism.rho_m.fixed = False\n", + "film_layer.magnetism.rho_m.bounds = (0, 8)\n", + "film_layer.magnetism.theta_m.fixed = False\n", + "film_layer.magnetism.theta_m.bounds = (0, 90)\n", + "\n", + "print('Free parameters (start values):')\n", + "for parameter in fit_model.get_fit_parameters():\n", + " print(f' {parameter.name:12s} = {float(parameter.value):8.3f} bounds={parameter.bounds}')\n", + "\n", + "fitter = MultiFitter(fit_model)\n", + "results = fitter.fit_polarized(data)\n", + "\n", + "print(f'\\nsuccess: {all(result.success for result in results.values())}')\n", + "print(f'reduced chi² (all channels): {fitter.reduced_chi:.3f}')\n", + "\n", + "print(f'\\n{\"Parameter\":12s} {\"truth\":>10s} {\"start\":>10s} {\"fitted\":>10s}')\n", + "print('-' * 46)\n", + "starts = {'thickness': 180.0, 'rho_m': 3.0, 'theta_m': 60.0}\n", + "fitted = {\n", + " 'thickness': float(film_layer.thickness.value),\n", + " 'rho_m': float(film_layer.magnetism.rho_m.value),\n", + " 'theta_m': float(film_layer.magnetism.theta_m.value),\n", + "}\n", + "for key in TRUTH:\n", + " print(f'{key:12s} {TRUTH[key]:>10.3f} {starts[key]:>10.3f} {fitted[key]:>10.3f}')" + ] + }, + { + "cell_type": "markdown", + "id": "plots-md", + "metadata": {}, + "source": [ + "## 9. Fitted Curves per Channel" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "plots-code", + "metadata": {}, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(2, 2, figsize=(12, 8), sharex=True)\n", + "\n", + "for ax, channel in zip(axes.flat, data.available_channels):\n", + " dataset = data[channel]\n", + " fitted_curve = fit_model.interface.reflectivity_profile_channel(dataset.x, fit_model.unique_name, channel)\n", + " ax.errorbar(\n", + " dataset.x, dataset.y, yerr=np.sqrt(dataset.ye), # ye holds variances\n", + " fmt='o', ms=2.5, alpha=0.45, color=CHANNEL_COLORS[channel.value], label='synthetic data',\n", + " )\n", + " ax.plot(dataset.x, fitted_curve, 'k-', lw=1.5, label='fit')\n", + " ax.set_yscale('log')\n", + " ax.set_title(CHANNEL_LABELS[channel.value])\n", + " ax.legend(fontsize=9)\n", + "\n", + "for ax in axes[1]:\n", + " ax.set_xlabel('Q (Å⁻¹)')\n", + "for ax in axes[:, 0]:\n", + " ax.set_ylabel('Reflectivity')\n", + "\n", + "fig.suptitle('Simultaneous four-channel fit — all channels share one model', y=1.0)\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "sa-fit-md", + "metadata": {}, + "source": [ + "### Spin asymmetry: data vs fit" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sa-fit-code", + "metadata": {}, + "outputs": [], + "source": [ + "fitted_channels = fit_model.interface.polarized_reflectivity_profiles(q, fit_model.unique_name)\n", + "\n", + "sa_data = (data['pp'].y - data['mm'].y) / (data['pp'].y + data['mm'].y)\n", + "sa_fit = (fitted_channels['pp'] - fitted_channels['mm']) / (fitted_channels['pp'] + fitted_channels['mm'])\n", + "\n", + "fig, ax = plt.subplots(figsize=(10, 4))\n", + "ax.plot(q, sa_data, 'o', ms=3, alpha=0.5, color='C5', label='synthetic data')\n", + "ax.plot(q, sa_fit, 'k-', lw=1.5, label='fit')\n", + "ax.axhline(0, color='k', lw=0.5)\n", + "ax.set_xlabel('Q (Å⁻¹)')\n", + "ax.set_ylabel('(R⁺⁺ − R⁻⁻) / (R⁺⁺ + R⁻⁻)')\n", + "ax.set_title('Spin asymmetry — data vs simultaneous fit')\n", + "ax.legend()\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "summary-md", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "- **`LayerMagnetism(rho_m, theta_m)`** makes a layer magnetic; both are fittable, bounded,\n", + " serializable `Parameter`s. Magnetism is enabled on the calculator automatically (refl1d\n", + " only).\n", + "- **`polarized_reflectivity_profiles`** / **`magnetic_sld_profile`** give the four spin\n", + " channels and the nuclear + magnetic depth profile in one call each.\n", + "- **`detect_polarization_channel`** assigns spin channels from ORSO headers or filename\n", + " tokens; partially-analysed observables (`po`/`mo` = channel sums) are never auto-assigned.\n", + "- **`PolarizedDataSet`** groups per-channel datasets (2-channel NSF-only works the same way:\n", + " provide just `pp` and `mm`) under one shared model; `ye` holds variances.\n", + "- **`MultiFitter.fit_polarized`** fits every measured channel simultaneously and recovered\n", + " thickness, `rho_m` and `theta_m` here to within a fraction of a percent, at roughly the\n", + " cost of a single-channel fit (four-channel cache: one kernel evaluation per iteration).\n", + "\n", + "For file-based / GUI workflows the same functionality is reachable through\n", + "`Project.suggest_polarized_channel_assignment(paths)` and\n", + "`Project.load_polarized_experiment({channel: path})`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pixi.toml b/pixi.toml index f0023fba..70a2a773 100644 --- a/pixi.toml +++ b/pixi.toml @@ -5,7 +5,7 @@ [workspace] # Supported platforms for the lock file (pixi.lock) -platforms = ['win-64', 'linux-64', 'osx-arm64'] +platforms = ['win-64', 'linux-64', { platform = 'osx-arm64', macos = '14.0' }] # Channels for fetching packages channels = ['nodefaults', 'conda-forge'] @@ -19,13 +19,6 @@ channels = ['nodefaults', 'conda-forge'] [activation.env] PYTHONIOENCODING = 'utf-8' -[system-requirements] -# Set minimum supported version for macOS to be 14.0 to ensure packages -# like `scipp` that only have wheels for macOS 14.0+ (macosx_14_0_arm64) -# are used instead of building from source. This is a workaround for -# Pixi, see https://github.com/prefix-dev/pixi/issues/5667 -macos = '14.0' - # Non-default features: # Set specific Python versions to be used in CI testing. diff --git a/src/easyreflectometry/calculators/__init__.py b/src/easyreflectometry/calculators/__init__.py index 9f7bde45..1282928d 100644 --- a/src/easyreflectometry/calculators/__init__.py +++ b/src/easyreflectometry/calculators/__init__.py @@ -5,6 +5,7 @@ from .calculator_base import CalculatorBase from .factory import CalculatorFactory +from .polarization import PolarizationChannel imported_calculators = [] @@ -31,4 +32,4 @@ traceback.print_exc() print('Warning: refl1d is not installed') -__all__ = ['CalculatorBase', 'CalculatorFactory'] + [c.__name__ for c in imported_calculators] +__all__ = ['CalculatorBase', 'CalculatorFactory', 'PolarizationChannel'] + [c.__name__ for c in imported_calculators] diff --git a/src/easyreflectometry/calculators/calculator_base.py b/src/easyreflectometry/calculators/calculator_base.py index e2a92804..dda986b1 100644 --- a/src/easyreflectometry/calculators/calculator_base.py +++ b/src/easyreflectometry/calculators/calculator_base.py @@ -201,6 +201,48 @@ def reflectity_profile(self, x_array: np.ndarray, model_id: str) -> np.ndarray: """ return self._wrapper.calculate(x_array, model_id) + def reflectivity_profile_channel(self, x_array: np.ndarray, model_id: str, channel) -> np.ndarray: + """Determine the reflectivity profile of one explicit spin channel. + + Unlike `polarization_channel` (global calculator state), the channel is an + argument, so several channels can be evaluated against the same model — + one per dataset in a simultaneous multi-channel fit. + + Parameters + ---------- + x_array : np.ndarray + Points to be calculated at. + model_id : str + The model id. + channel : PolarizationChannel | str + One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member). + + Returns + ------- + np.ndarray + Reflectivity of the requested channel at q. + """ + return self._wrapper.calculate_channel(x_array, model_id, channel) + + def polarized_reflectivity_profiles(self, x_array: np.ndarray, model_id: str) -> dict[str, np.ndarray]: + """Determines the reflectivity profiles of all four spin channels for the given range and model. + + Requires `include_magnetism` to be enabled and a calculator that supports it (refl1d). + + Parameters + ---------- + x_array : np.ndarray + Points to be calculated at. + model_id : str + The model id. + + Returns + ------- + dict[str, np.ndarray] + Reflectivity per spin channel, keyed 'pp', 'pm', 'mp', 'mm' (in that order). + """ + return self._wrapper.calculate_polarized(x_array, model_id) + def sld_profile(self, model_id: str) -> tuple[np.ndarray, np.ndarray]: """Return the scattering length density profile. @@ -216,10 +258,43 @@ def sld_profile(self, model_id: str) -> tuple[np.ndarray, np.ndarray]: """ return self._wrapper.sld_profile(model_id) + def magnetic_sld_profile(self, model_id: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return the nuclear and magnetic scattering length density profiles. + + Requires `include_magnetism` to be enabled and a calculator that supports it (refl1d). + + Parameters + ---------- + model_id : str + The model id. + + Returns + ------- + tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] + z, sld(z), magnetic sld rhoM(z) and magnetic angle thetaM(z). + """ + return self._wrapper.magnetic_sld_profile(model_id) + def set_resolution_function(self, resolution_function: Callable[[np.array], np.array]) -> None: """Set resolution function.""" return self._wrapper.set_resolution_function(resolution_function) + @property + def supports_magnetism(self) -> bool: + """Whether this calculator backend can model magnetic samples.""" + return self._wrapper.supports_magnetism + + def remove_layer_magnetism(self, layer_id: str) -> None: + """Remove the magnetic state of one layer; disables `include_magnetism` + when no magnetic layer is left. + + Parameters + ---------- + layer_id : str + The layer id. + """ + self._wrapper.remove_layer_magnetism(layer_id) + @property def include_magnetism(self): """Include magnetism.""" @@ -235,3 +310,23 @@ def include_magnetism(self, magnetism: bool): True if the calculator should include magnetism. """ self._wrapper.magnetism = magnetism + + @property + def polarization_channel(self): + """The spin channel ('pp', 'pm', 'mp' or 'mm') used by `reflectity_profile` when magnetism is enabled. + + Note: this state belongs to the currently-active calculator instance; switching + calculators via the factory constructs a fresh instance and resets it. + """ + return self._wrapper.polarization_channel + + @polarization_channel.setter + def polarization_channel(self, channel) -> None: + """Set the spin channel for reflectivity calculations. + + Parameters + ---------- + channel : PolarizationChannel | str + One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member). + """ + self._wrapper.polarization_channel = channel diff --git a/src/easyreflectometry/calculators/factory.py b/src/easyreflectometry/calculators/factory.py index c3e1479c..b39f1daa 100644 --- a/src/easyreflectometry/calculators/factory.py +++ b/src/easyreflectometry/calculators/factory.py @@ -8,6 +8,8 @@ from easyreflectometry.calculators import CalculatorBase +from .polarization import PolarizationChannel + class CalculatorFactory(InterfaceFactoryTemplate): def __init__(self): @@ -22,6 +24,18 @@ def sld_profile(self, model_id: str) -> tuple: """Sld profile.""" return self().sld_profile(model_id) + def polarized_reflectivity_profiles(self, x_array, model_id: str) -> dict: + """Reflectivity profiles of all four spin channels ('pp', 'pm', 'mp', 'mm').""" + return self().polarized_reflectivity_profiles(x_array, model_id) + + def reflectivity_profile_channel(self, x_array, model_id: str, channel: PolarizationChannel | str): + """Reflectivity profile of one explicit spin channel ('pp', 'pm', 'mp' or 'mm').""" + return self().reflectivity_profile_channel(x_array, model_id, channel) + + def magnetic_sld_profile(self, model_id: str) -> tuple: + """Nuclear and magnetic sld profiles: z, sld(z), rhoM(z) and thetaM(z).""" + return self().magnetic_sld_profile(model_id) + @property def fit_func(self) -> Callable: """Fit func.""" @@ -43,3 +57,28 @@ def __fit_func(*args, **kwargs): return self().reflectity_profile(*args, **kwargs) return __fit_func + + def fit_func_for_channel(self, channel: PolarizationChannel | str) -> Callable: + """A fit function evaluating one explicit spin channel. + + Used for simultaneous multi-channel fitting: each channel dataset gets its + own fit function while all of them share the same model (and hence the + same parameters). + + Parameters + ---------- + channel : PolarizationChannel | str + One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member). + + Returns + ------- + Callable + Function of (x_array, model_id) returning the channel reflectivity. + """ + channel = PolarizationChannel(channel) + + def __fit_func(x_array, model_id): + """Fit func for one spin channel.""" + return self().reflectivity_profile_channel(x_array, model_id, channel) + + return __fit_func diff --git a/src/easyreflectometry/calculators/polarization.py b/src/easyreflectometry/calculators/polarization.py new file mode 100644 index 00000000..16717bd2 --- /dev/null +++ b/src/easyreflectometry/calculators/polarization.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from enum import Enum + + +class PolarizationChannel(str, Enum): + """Spin cross-section channels for polarized neutron reflectometry. + + The accepted spellings are exactly the enum values ('pp', 'pm', 'mp', 'mm'); + uppercase strings are rejected. + """ + + PP = 'pp' # non-spin-flip, up-up + PM = 'pm' # spin-flip, up-down + MP = 'mp' # spin-flip, down-up + MM = 'mm' # non-spin-flip, down-down + + +# Mapping to refl1d cross-section indices. refl1d returns the polarized +# cross-sections in the order of `PolarizedNeutronProbe._xs_names`, which is +# ['mm', 'mp', 'pm', 'pp'] (refl1d/probe/probe.py); `magnetic_amplitude` returns +# (--, -+, +-, ++) accordingly (refl1d/sample/reflectivity.py). The pp/mm +# assignment is pinned by a physics test (moment aligned with the guide field: +# pp must see rho + rhoM); pm vs mp rests on the refl1d source alone (they are +# identical by symmetry for non-chiral, non-absorptive samples). +# The key order (pp, pm, mp, mm) is the canonical channel order used throughout. +POLARIZATION_CHANNEL_TO_INDEX = { + PolarizationChannel.PP: 3, + PolarizationChannel.PM: 2, + PolarizationChannel.MP: 1, + PolarizationChannel.MM: 0, +} diff --git a/src/easyreflectometry/calculators/refl1d/calculator.py b/src/easyreflectometry/calculators/refl1d/calculator.py index 2f5068de..ac7214e2 100644 --- a/src/easyreflectometry/calculators/refl1d/calculator.py +++ b/src/easyreflectometry/calculators/refl1d/calculator.py @@ -19,6 +19,8 @@ class Refl1d(CalculatorBase): _layer_link = { 'thickness': 'thickness', 'roughness': 'interface', + 'rho_m': 'magnetism_rhoM', + 'theta_m': 'magnetism_thetaM', } _item_link = { diff --git a/src/easyreflectometry/calculators/refl1d/wrapper.py b/src/easyreflectometry/calculators/refl1d/wrapper.py index 6985d47c..9400bdf4 100644 --- a/src/easyreflectometry/calculators/refl1d/wrapper.py +++ b/src/easyreflectometry/calculators/refl1d/wrapper.py @@ -2,20 +2,49 @@ # SPDX-License-Identifier: BSD-3-Clause +import logging from typing import Tuple import numpy as np from refl1d import names +from refl1d.profile import build_profile from refl1d.sample.layers import Repeat +from ..polarization import POLARIZATION_CHANNEL_TO_INDEX from ..wrapper_base import WrapperBase +logger = logging.getLogger(__name__) + RESOLUTION_PADDING = 3.5 OVERSAMPLING_FACTOR = 21 -ALL_POLARIZATIONS = False + +# refl1d convention: with the default guide field (Aguide = 270 deg) a moment at +# thetaM = 270 deg is aligned with the field, i.e. produces no spin-flip. +DEFAULT_THETA_M = 270.0 class Refl1dWrapper(WrapperBase): + supports_magnetism = True + + def __init__(self): + """Constructor.""" + super().__init__() + # Magnetic values per layer name, kept outside the slabs so they survive + # magnetism being toggled off/on and can be set before it is enabled. + self._layer_magnetism: dict[str, dict[str, float]] = {} + # Per-model cache of polarized reflectivities: all four cross-sections come + # from a single kernel evaluation, so a simultaneous multi-channel fit costs + # one evaluation per iteration instead of one per channel. Keyed on a token + # of every model input; entries per (q, dq) grid, so channels measured on + # different grids coexist within one iteration. + self._polarized_cache: dict[str, dict] = {} + + def reset_storage(self): + """Reset the storage area (including stored magnetic values) to blank.""" + super().reset_storage() + self._layer_magnetism = {} + self._polarized_cache = {} + def create_material(self, name: str): """Create a material using SLD. @@ -35,7 +64,11 @@ def create_layer(self, name: str): The name of the layer. """ if self._magnetism: - magnetism = names.Magnetism(rhoM=0.0, thetaM=0.0) + values = self._layer_magnetism.get(name, {}) + magnetism = names.Magnetism( + rhoM=values.get('rhoM', 0.0), + thetaM=values.get('thetaM', DEFAULT_THETA_M), + ) else: magnetism = None self.storage['layer'][name] = names.Slab(name=str(name), magnetism=magnetism) @@ -54,17 +87,24 @@ def create_item(self, name: str): def update_layer(self, name: str, **kwargs): """Update a layer in a given item. + Magnetic keys (`magnetism_rhoM`, `magnetism_thetaM`) may be passed alone or + together; values are stored per layer and attached to the slab when + magnetism is enabled. + Parameters ---------- name : str The layer name. **kwargs : """ - kwargs_no_magnetism = {k: v for k, v in kwargs.items() if k != 'magnetism_rhoM' and k != 'magnetism_thetaM'} + magnetic_values = {k.removeprefix('magnetism_'): v for k, v in kwargs.items() if k.startswith('magnetism_')} + kwargs_no_magnetism = {k: v for k, v in kwargs.items() if not k.startswith('magnetism_')} super().update_layer(name, **kwargs_no_magnetism) - if any(item.startswith('magnetism') for item in kwargs.keys()): - magnetism = names.Magnetism(rhoM=kwargs['magnetism_rhoM'], thetaM=kwargs['magnetism_thetaM']) - self.storage['layer'][name].magnetism = magnetism + if magnetic_values: + stored = self._layer_magnetism.setdefault(name, {'rhoM': 0.0, 'thetaM': DEFAULT_THETA_M}) + stored.update(magnetic_values) + if self._magnetism: + self._apply_magnetism_to_layer(name) def get_layer_value(self, name: str, key: str) -> float: """A function to get a given layer value. @@ -77,11 +117,59 @@ def get_layer_value(self, name: str, key: str) -> float: The given value keys. """ if key in ['magnetism_rhoM', 'magnetism_thetaM']: - return getattr( - self.storage['layer'][name].magnetism, key.split('_')[-1] - ).value # TODO: check if we want to return the raw value or the full Parameter # noqa: E501 + defaults = {'rhoM': 0.0, 'thetaM': DEFAULT_THETA_M} + magnetic_key = key.removeprefix('magnetism_') + return self._layer_magnetism.get(name, defaults).get(magnetic_key, defaults[magnetic_key]) return super().get_layer_value(name, key) + def _remove_magnetism_from_layers(self) -> None: + """Detach Magnetism objects from all slabs. + + Called when magnetism is disabled: slabs carrying Magnetism objects would + crash refl1d's plain (unpolarized) QProbe path. The magnetic values remain + stored and are re-attached when magnetism is re-enabled. + """ + for layer in self.storage['layer'].values(): + layer.magnetism = None + + def _apply_magnetism_to_layers(self) -> None: + """Attach stored magnetic values to all slabs (called when magnetism is enabled).""" + for name in self.storage['layer']: + self._apply_magnetism_to_layer(name) + + def _apply_magnetism_to_layer(self, name: str) -> None: + """Attach the stored magnetic values (or defaults) of one layer to its slab.""" + values = self._layer_magnetism.get(name, {}) + slab = self.storage['layer'][name] + slab.magnetism = names.Magnetism( + rhoM=values.get('rhoM', 0.0), + thetaM=values.get('thetaM', DEFAULT_THETA_M), + ) + + def remove_layer_magnetism(self, name: str) -> None: + """Remove the magnetic state of one layer; disable magnetism when none is left. + + Keeps `magnetism` (the calculator flag) in sync with the model: once no + layer holds magnetic values any more, the polarized calculation path is + switched off entirely. + + Parameters + ---------- + name : str + The layer name. + """ + self._layer_magnetism.pop(name, None) + slab = self.storage['layer'].get(name) + if slab is not None: + # A non-magnetic slab is fine inside a polarized calculation. + slab.magnetism = None + if self._magnetism and not self._layer_magnetism: + # Goes through the `magnetism` property setter, which calls + # `_remove_magnetism_from_layers()` again (a no-op here since + # `_layer_magnetism` is already empty) and, more importantly, + # resets `_polarization_channel` back to PP. + self.magnetism = False + def create_model(self, name: str): """Create a model for analysis. @@ -202,44 +290,120 @@ def calculate(self, q_array: np.ndarray, model_name: str) -> np.ndarray: np.ndarray Reflectivity calculated at q. """ + if self._magnetism: + reflectivities = self._polarized_reflectivities(q_array, model_name) + # Copy: the arrays live in the polarized cache and must not be mutated. + return reflectivities[POLARIZATION_CHANNEL_TO_INDEX[self._polarization_channel]].copy() + sample = _build_sample(self.storage, model_name) # smearing() returns sigma, which is exactly what refl1d's probe.dQ expects. dq_array = self._resolution_function.smearing(q_array) + probe = _get_probe( + q_array=q_array, + dq_array=dq_array, + model_name=model_name, + storage=self.storage, + oversampling_factor=OVERSAMPLING_FACTOR, + ) + # returns q, reflectivity + _, reflectivity = names.Experiment(probe=probe, sample=sample).reflectivity() + return reflectivity + + def calculate_polarized(self, q_array: np.ndarray, model_name: str) -> dict[str, np.ndarray]: + """For a given q array calculate the reflectivity of all four spin channels. + + Parameters + ---------- + q_array : np.ndarray + Array of data points to be calculated. + model_name : str + The model name. + Returns + ------- + dict[str, np.ndarray] + Reflectivity per spin channel, keyed 'pp', 'pm', 'mp', 'mm' (in that order). + """ if not self._magnetism: - probe = _get_probe( - q_array=q_array, - dq_array=dq_array, - model_name=model_name, - storage=self.storage, - oversampling_factor=OVERSAMPLING_FACTOR, + raise ValueError( + 'Polarized reflectivity requires magnetism: enable it on this calculator first ' + '(`include_magnetism = True` on the calculator / `magnetism = True` on the wrapper).' ) - # returns q, reflectivity - _, reflectivity = names.Experiment(probe=probe, sample=sample).reflectivity() + reflectivities = self._polarized_reflectivities(q_array, model_name) + # Copies: the arrays live in the polarized cache and must not be mutated. + return {channel.value: reflectivities[index].copy() for channel, index in POLARIZATION_CHANNEL_TO_INDEX.items()} + + def _model_state_token(self, model_name: str) -> tuple: + """A token of every model input that affects the reflectivity. + + Two calls with equal tokens (and equal q/dq grids) are guaranteed to + produce the same reflectivity, so cached cross-sections can be reused. + The resolution function needs no entry here — it enters through the dq + part of the per-grid cache key. Must be extended whenever a new slab or + material attribute starts reaching the kernel: a forgotten kernel input + would silently serve stale reflectivities whenever only that input + changes, since the token would compare equal. + """ + model = self.storage['model'][model_name] + values: list = [model['scale'], model['bkg']] + for item in model['items']: + values.append(item.repeat.value) + for slab in item.stack: + values.extend(( + slab.thickness.value, + slab.interface.value, + slab.material.rho.value, + slab.material.irho.value, + )) + if slab.magnetism is None: + values.append(None) + else: + values.extend((slab.magnetism.rhoM.value, slab.magnetism.thetaM.value)) + return tuple(values) + + def _polarized_reflectivities(self, q_array: np.ndarray, model_name: str) -> list: + """Reflectivity of the four spin cross-sections, in refl1d order (mm, mp, pm, pp). + + The list follows `PolarizedNeutronProbe._xs_names`; use + `POLARIZATION_CHANNEL_TO_INDEX` to pick a channel out of it. + Results are cached per model state and (q, dq) grid; see `_polarized_cache`. + """ + # Normalized dtype plus explicit shape in the key: raw bytes alone do not + # uniquely identify an ndarray (equal bytes can encode different + # dtype/shape combinations), which could return a wrong-length hit. + q_array = np.asarray(q_array, dtype=np.float64) + dq_array = np.asarray(self._resolution_function.smearing(q_array), dtype=np.float64) + + token = self._model_state_token(model_name) + grid_key = (q_array.shape, q_array.tobytes(), dq_array.shape, dq_array.tobytes()) + cache = self._polarized_cache.get(model_name) + if cache is not None and cache['token'] == token: + cached = cache['entries'].get(grid_key) + if cached is not None: + return cached else: - polarized_probe = _get_polarized_probe( - q_array=q_array, - dq_array=dq_array, - model_name=model_name, - storage=self.storage, - oversampling_factor=OVERSAMPLING_FACTOR, - all_polarizations=ALL_POLARIZATIONS, - ) - polarized_reflectivity = names.Experiment(probe=polarized_probe, sample=sample).reflectivity() - - if ALL_POLARIZATIONS: - raise NotImplementedError('Polarized reflectivity not yet implemented') - # returns q, reflectivity - # _, reflectivity_pp = polarized_reflectivity[0] - # _, reflectivity_pm = polarized_reflectivity[1] - # _, reflectivity_mp = polarized_reflectivity[2] - # _, reflectivity_mm = polarized_reflectivity[3] - else: - # Only pick the pp reflectivity - # returns q, reflectivity - _, reflectivity = polarized_reflectivity[0] + cache = {'token': token, 'entries': {}} + self._polarized_cache[model_name] = cache - return reflectivity + sample = _build_sample(self.storage, model_name) + polarized_probe = _get_polarized_probe( + q_array=q_array, + dq_array=dq_array, + model_name=model_name, + storage=self.storage, + oversampling_factor=OVERSAMPLING_FACTOR, + ) + polarized_reflectivity = names.Experiment(probe=polarized_probe, sample=sample).reflectivity() + + # returns (q, reflectivity) per cross-section + reflectivities = [reflectivity for _, reflectivity in polarized_reflectivity] + if len(reflectivities) != 4: + raise RuntimeError(f'refl1d returned {len(reflectivities)} polarized cross-sections; expected 4.') + for channel, index in POLARIZATION_CHANNEL_TO_INDEX.items(): + if len(reflectivities[index]) != len(q_array) or not np.all(np.isfinite(reflectivities[index])): + raise RuntimeError(f'refl1d returned a malformed {channel.value} cross-section.') + cache['entries'][grid_key] = reflectivities + return reflectivities def sld_profile(self, model_name: str) -> Tuple[np.ndarray, np.ndarray]: """Return the scattering length density profile. @@ -265,6 +429,93 @@ def sld_profile(self, model_name: str) -> Tuple[np.ndarray, np.ndarray]: # -1 to reverse the order return z, sld[::-1] + def magnetic_sld_profile(self, model_name: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return the nuclear and magnetic scattering length density profiles. + + The magnetic profile is built by smoothing the two in-plane components + of the moment and converting back, not by smoothing its magnitude and + angle separately — see :meth:`_smoothed_magnetic_vector`. + + Parameters + ---------- + model_name : str + The model name. + + Returns + ------- + + z, sld(z), magnetic sld rhoM(z) and magnetic angle thetaM(z). + """ + if not self._magnetism: + raise ValueError( + 'The magnetic sld profile requires magnetism: enable it on this calculator first ' + '(`include_magnetism = True` on the calculator / `magnetism = True` on the wrapper).' + ) + sample = _build_sample(self.storage, model_name) + # Plain (non-polarized) probe: unlike `_polarized_reflectivities`, this + # only renders slabs for `magnetic_smooth_profile()`/`_render_slabs()`, + # never computes a per-channel reflectivity, so the `theta_offset` that + # `magnetism=True` would add (needed by `PolarizedQProbe`) is not required. + probe = _get_probe( + q_array=np.array([1]), # dummy value + dq_array=np.array([1]), # dummy value + model_name=model_name, + storage=self.storage, + ) + experiment = names.Experiment(probe=probe, sample=sample) + z, sld, _, _, _ = experiment.magnetic_smooth_profile() + sld_magnetic, theta_magnetic = self._smoothed_magnetic_vector(experiment, z) + # -1 to reverse the order + return z, sld[::-1], sld_magnetic[::-1], theta_magnetic[::-1] + + @staticmethod + def _smoothed_magnetic_vector(experiment, z: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """Magnitude and angle of the smoothed in-plane moment. + + refl1d smooths the magnetic microslabs channel by channel, so the + magnitude |rhoM| and the angle thetaM are interpolated independently + across an interface. For two layers whose moments differ by a couple of + degrees around 0/360 (e.g. 359 and 1) the angle then takes the long way + round the circle, passing through the guide-field direction: the profile + reports the *full* moment as longitudinal exactly where it is almost + entirely transverse, which shows up as a spurious spin-up/spin-down + splitting at the interface. + + Smoothing the Cartesian components instead and converting back is + interpolation of the moment as a vector, which is what the physics does. + The reference angle used for the decomposition cancels out. + + Raises + ------ + NotImplementedError + The installed refl1d does not expose the microslab data this needs. + Falling back to its angle-smoothed profile is deliberately *not* + done: that output is wrong in a way a user cannot see, and a silent + change of results after a dependency update is worse than no + profile at all. + """ + try: + slabs = experiment._render_slabs() + offsets = np.cumsum(slabs.w[:-1]) + slabs._z_offset + roughness = slabs.sigma + rho_m = np.asarray(slabs.rhoM, dtype=float) + theta_m = np.asarray(slabs.thetaM, dtype=float) + except (AttributeError, IndexError, TypeError) as exception: # pragma: no cover - refl1d internals + raise NotImplementedError( + 'The installed refl1d does not provide the microslab data needed for a ' + f'component-safe magnetic profile ({exception}). The magnetic depth profile is ' + "unavailable; refl1d's own profile smooths the moment angle separately, which " + 'misreports the spin-up/spin-down splitting at interfaces between differently ' + 'oriented moments.' + ) from exception + + relative_angle = np.radians(theta_m - DEFAULT_THETA_M) + parallel = build_profile(z, offsets, roughness, rho_m * np.cos(relative_angle)) + perpendicular = build_profile(z, offsets, roughness, rho_m * np.sin(relative_angle)) + magnitude = np.hypot(parallel, perpendicular) + angle = (DEFAULT_THETA_M + np.degrees(np.arctan2(perpendicular, parallel))) % 360.0 + return magnitude, angle + def _get_oversampling_q(q_array: np.ndarray, dq_array: np.ndarray, oversampling_factor: int) -> np.ndarray: """Get oversampling q.""" @@ -294,7 +545,10 @@ def _get_probe( ) # Add theta_offset attribute if magnetism is enabled - # This is required for PolarizedQProbe to work correctly + # This is required for PolarizedQProbe to work correctly: refl1d's + # `PolarizedNeutronQProbe.__init__` -> `_calculate_union` reads `theta_offset` + # off each constituent probe, so a QProbe destined for a PolarizedQProbe must + # carry it even though the plain (unpolarized) QProbe path never touches it. if magnetism: probe.theta_offset = names.Parameter.default(0, name='theta_offset') @@ -309,28 +563,31 @@ def _get_polarized_probe( model_name: str, storage: dict, oversampling_factor: int = 1, - all_polarizations: bool = False, ) -> names.PolarizedNeutronQProbe: - """Get polarized probe.""" - four_probes = [] - for i in range(4): - if i == 0 or all_polarizations: - probe = _get_probe( - q_array=q_array, - dq_array=dq_array, - model_name=model_name, - storage=storage, - oversampling_factor=oversampling_factor, - magnetism=True, # Enable magnetism for polarized probes - ) - else: - probe = None - four_probes.append(probe) - - # Create polarized probe and work around initialization bug - polarized_probe = names.PolarizedNeutronQProbe.__new__(names.PolarizedNeutronQProbe) - polarized_probe._union_cache_key = None # Initialize missing attribute - polarized_probe.__init__(xs=four_probes, name='polarized') + """Get polarized probe with all four cross-sections (pp, pm, mp, mm).""" + four_probes = [ + _get_probe( + q_array=q_array, + dq_array=dq_array, + model_name=model_name, + storage=storage, + oversampling_factor=oversampling_factor, + magnetism=True, # Enable magnetism for polarized probes + ) + for _ in range(4) + ] + + try: + polarized_probe = names.PolarizedNeutronQProbe(xs=four_probes, name='polarized') + except AttributeError: + # refl1d 1.0.0 bug: PolarizedQProbe.__init__ calls _calculate_union(), which + # reads self._union_cache_key before the attribute is ever assigned (the + # non-Q PolarizedNeutronProbe assigns it in __init__; the Q variant does + # not). Pre-seed the attribute and re-run __init__. The try/except makes + # the workaround self-removing once refl1d fixes the initialization. + polarized_probe = names.PolarizedNeutronQProbe.__new__(names.PolarizedNeutronQProbe) + polarized_probe._union_cache_key = None + polarized_probe.__init__(xs=four_probes, name='polarized') return polarized_probe diff --git a/src/easyreflectometry/calculators/refnx/wrapper.py b/src/easyreflectometry/calculators/refnx/wrapper.py index 65dc8662..3ebb0bc4 100644 --- a/src/easyreflectometry/calculators/refnx/wrapper.py +++ b/src/easyreflectometry/calculators/refnx/wrapper.py @@ -14,22 +14,6 @@ class RefnxWrapper(WrapperBase): - @property - def include_magnetism(self) -> bool: - """Include magnetism.""" - return self._magnetism - - @include_magnetism.setter - def include_magnetism(self, magnetism: bool) -> None: - """Set the magnetism flag. - - Parameters - ---------- - magnetism : bool - The magnetism flag. - """ - raise NotImplementedError('Magnetism is not supported by refnx') - def create_material(self, name: str): """Create a material using SLD. diff --git a/src/easyreflectometry/calculators/wrapper_base.py b/src/easyreflectometry/calculators/wrapper_base.py index dc53ceca..32e1d4be 100644 --- a/src/easyreflectometry/calculators/wrapper_base.py +++ b/src/easyreflectometry/calculators/wrapper_base.py @@ -8,11 +8,17 @@ from easyreflectometry.model import PercentageFwhm from easyreflectometry.model import ResolutionFunction +from .polarization import PolarizationChannel + class WrapperBase: + #: Whether this calculator backend can model magnetic samples. + supports_magnetism = False + def __init__(self): """Constructor.""" self._magnetism = False + self._polarization_channel = PolarizationChannel.PP self.storage = { 'material': {}, 'layer': {}, @@ -317,4 +323,120 @@ def magnetism(self, magnetism: bool) -> None: magnetism : bool The magnetism flag. """ + if magnetism and not self.supports_magnetism: + raise NotImplementedError(f'Magnetism is not supported by {self.__class__.__name__}') self._magnetism = magnetism + if magnetism: + # Attach any magnetic values set (or restored) while magnetism was off. + self._apply_magnetism_to_layers() + else: + # A non-pp channel is only meaningful on the polarized probe path. + self._polarization_channel = PolarizationChannel.PP + # Leave no magnetic residue behind: the unpolarized calculation path + # must work on the layers that already exist. + self._remove_magnetism_from_layers() + + def _remove_magnetism_from_layers(self) -> None: + """Strip backend magnetism state from existing layers when magnetism is disabled. + + No-op by default; overridden by backends that attach magnetic objects to layers. + """ + + def _apply_magnetism_to_layers(self) -> None: + """Attach stored magnetic values to existing layers when magnetism is enabled. + + No-op by default; overridden by backends that attach magnetic objects to layers. + """ + + def remove_layer_magnetism(self, name: str) -> None: + """Remove the magnetic state of one layer; disable magnetism when none is left. + + No-op by default; overridden by backends that support magnetism. + + Parameters + ---------- + name : str + The layer name. + """ + + @property + def polarization_channel(self) -> PolarizationChannel: + """The spin channel returned by `calculate` when magnetism is enabled.""" + return self._polarization_channel + + @polarization_channel.setter + def polarization_channel(self, channel: PolarizationChannel | str) -> None: + """Set the spin channel returned by `calculate`. + + Parameters + ---------- + channel : PolarizationChannel | str + One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member). + """ + channel = PolarizationChannel(channel) + if channel is not PolarizationChannel.PP and not self._magnetism: + raise ValueError(f"Selecting the '{channel.value}' channel requires magnetism to be enabled.") + self._polarization_channel = channel + + def calculate_channel(self, q_array: np.ndarray, model_name: str, channel: PolarizationChannel | str) -> np.ndarray: + """For a given q array calculate the reflectivity of one explicit spin channel. + + Unlike the `polarization_channel` property (global calculator state used by + `calculate`), the channel is passed explicitly, so several channels can be + evaluated against the same model, e.g. one per dataset in a simultaneous + multi-channel fit. + + Parameters + ---------- + q_array : np.ndarray + Array of data points to be calculated. + model_name : str + The model name. + channel : PolarizationChannel | str + One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member). + + Returns + ------- + np.ndarray + Reflectivity of the requested channel at q. + """ + channel = PolarizationChannel(channel) + if not self._magnetism: + if channel is PolarizationChannel.PP: + # No explicit `.copy()` needed here: `calculate()` always returns + # a fresh array (a cached, shared array only exists on the + # magnetism-enabled `calculate_polarized` path below). + return self.calculate(q_array, model_name) + raise ValueError(f"Calculating the '{channel.value}' channel requires magnetism to be enabled.") + return self.calculate_polarized(q_array, model_name)[channel.value] + + def calculate_polarized(self, q_array: np.ndarray, model_name: str) -> dict[str, np.ndarray]: + """For a given q array calculate the reflectivity of all four spin channels. + + Parameters + ---------- + q_array : np.ndarray + Array of data points to be calculated. + model_name : str + The model name. + + Returns + ------- + dict[str, np.ndarray] + Reflectivity per spin channel, keyed 'pp', 'pm', 'mp', 'mm' (in that order). + """ + raise NotImplementedError(f'{self.__class__.__name__} does not support polarized reflectivity.') + + def magnetic_sld_profile(self, model_name: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return the nuclear and magnetic scattering length density profiles. + + Parameters + ---------- + model_name : str + The model name. + + Returns + ------- + z, sld(z), magnetic sld rhoM(z) and magnetic angle thetaM(z). + """ + raise NotImplementedError(f'{self.__class__.__name__} does not support magnetic sld profiles.') diff --git a/src/easyreflectometry/data/__init__.py b/src/easyreflectometry/data/__init__.py index 0d058120..e63470eb 100644 --- a/src/easyreflectometry/data/__init__.py +++ b/src/easyreflectometry/data/__init__.py @@ -6,6 +6,8 @@ from .measurement import load from .measurement import load_as_dataset from .measurement import merge_datagroups +from .polarized import PolarizedDataSet +from .polarized import detect_polarization_channel __all__ = [ 'load', @@ -13,4 +15,6 @@ 'merge_datagroups', 'ProjectData', 'DataSet1D', + 'PolarizedDataSet', + 'detect_polarization_channel', ] diff --git a/src/easyreflectometry/data/polarized.py b/src/easyreflectometry/data/polarized.py new file mode 100644 index 00000000..ae8fcac8 --- /dev/null +++ b/src/easyreflectometry/data/polarized.py @@ -0,0 +1,284 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +"""Polarized experiment data: per-spin-channel datasets and channel detection. + +A polarized neutron reflectometry experiment measures up to four spin +cross-sections (non-spin-flip 'pp'/'mm', spin-flip 'pm'/'mp'), typically stored +as one file per channel. `PolarizedDataSet` groups those per-channel +`DataSet1D` objects into a single experiment sharing one model. +""" + +from __future__ import annotations + +import os +import re +from types import MappingProxyType +from typing import Mapping +from typing import Optional +from typing import Union + +from easyreflectometry.calculators.polarization import POLARIZATION_CHANNEL_TO_INDEX +from easyreflectometry.calculators.polarization import PolarizationChannel + +from .data_store import DataSet1D + +# ORSO instrument_settings.polarization values → spin channel. +# Only the four fully-analysed cross-sections are mapped. Partially-analysed +# observables are deliberately NOT: 'po' (incident plus, no outgoing analysis) +# measures the sum pp + pm — a physically different observable from the pp +# cross-section — and likewise 'mo' = mp + mm; 'op'/'om' analyse only the +# outgoing spin; 'unpolarized' is no channel at all. A header declaring any of +# those suppresses the filename fallback and the user must assign (or the data +# must be modelled as a channel sum, which this formalism does not do yet). +_ORSO_POLARIZATION_TO_CHANNEL = { + 'pp': PolarizationChannel.PP, + 'pm': PolarizationChannel.PM, + 'mp': PolarizationChannel.MP, + 'mm': PolarizationChannel.MM, +} + +# Filename tokens → spin channel (used when there is no ORSO header). +_TOKEN_TO_CHANNEL = { + 'pp': PolarizationChannel.PP, + 'uu': PolarizationChannel.PP, + 'upup': PolarizationChannel.PP, + 'plusplus': PolarizationChannel.PP, + 'mm': PolarizationChannel.MM, + 'dd': PolarizationChannel.MM, + 'downdown': PolarizationChannel.MM, + 'minusminus': PolarizationChannel.MM, + 'pm': PolarizationChannel.PM, + 'ud': PolarizationChannel.PM, + 'updown': PolarizationChannel.PM, + 'mp': PolarizationChannel.MP, + 'du': PolarizationChannel.MP, + 'downup': PolarizationChannel.MP, +} + +# Single tokens for 2-channel (NSF-only) naming like 'sample_up.dat' / 'sample_down.dat'. +_SINGLE_TOKEN_TO_CHANNEL = { + 'u': PolarizationChannel.PP, + 'up': PolarizationChannel.PP, + 'p': PolarizationChannel.PP, + 'plus': PolarizationChannel.PP, + 'd': PolarizationChannel.MM, + 'down': PolarizationChannel.MM, + 'm': PolarizationChannel.MM, + 'minus': PolarizationChannel.MM, +} + +# Adjacent token pairs like 'up_down' → channel. +_TOKEN_PAIR_TO_CHANNEL = { + ('up', 'up'): PolarizationChannel.PP, + ('up', 'down'): PolarizationChannel.PM, + ('down', 'up'): PolarizationChannel.MP, + ('down', 'down'): PolarizationChannel.MM, + ('u', 'u'): PolarizationChannel.PP, + ('u', 'd'): PolarizationChannel.PM, + ('d', 'u'): PolarizationChannel.MP, + ('d', 'd'): PolarizationChannel.MM, + ('plus', 'plus'): PolarizationChannel.PP, + ('plus', 'minus'): PolarizationChannel.PM, + ('minus', 'plus'): PolarizationChannel.MP, + ('minus', 'minus'): PolarizationChannel.MM, +} + + +class PolarizedDataSet: + """A polarized experiment: one dataset per measured spin channel, one shared model. + + Parameters + ---------- + name : str, optional + Name of the experiment. By default, 'PolarizedSeries'. + channels : dict[PolarizationChannel | str, DataSet1D] + The measured channels; at least one. Keys are 'pp', 'pm', 'mp', 'mm' + (or the corresponding enum members). Two-channel NSF-only experiments + simply provide 'pp' and 'mm'. + model : Model, optional + The model shared by all channels. By default, None. + """ + + def __init__( + self, + name: str = 'PolarizedSeries', + channels: Optional[dict[Union[PolarizationChannel, str], DataSet1D]] = None, + model=None, + ): + if not channels: + raise ValueError('A PolarizedDataSet requires at least one channel dataset.') + normalized: dict[PolarizationChannel, DataSet1D] = {} + for channel, dataset in channels.items(): + channel = PolarizationChannel(channel) + if channel in normalized: + raise ValueError(f"Duplicate channel '{channel.value}'.") + normalized[channel] = self._validated_dataset(channel, dataset) + self._channels = self._in_canonical_order(normalized) + self.name = name + self.model = model + + @staticmethod + def _validated_dataset(channel: PolarizationChannel, dataset: DataSet1D) -> DataSet1D: + if not isinstance(dataset, DataSet1D): + raise ValueError(f"Channel '{channel.value}' must be a DataSet1D, got {type(dataset).__name__}.") + return dataset + + @staticmethod + def _in_canonical_order(channels: dict[PolarizationChannel, DataSet1D]) -> dict[PolarizationChannel, DataSet1D]: + # Canonical channel order: pp, pm, mp, mm. + return {channel: channels[channel] for channel in POLARIZATION_CHANNEL_TO_INDEX if channel in channels} + + @property + def model(self): + """The model shared by all channels.""" + return self._model + + @model.setter + def model(self, new_model) -> None: + self._model = new_model + for dataset in self._channels.values(): + dataset.model = new_model + + @property + def channels(self) -> Mapping[PolarizationChannel, DataSet1D]: + """The measured channels, in canonical order (pp, pm, mp, mm). + + Read-only view: use :meth:`set_channel` / :meth:`remove_channel` to + modify the channel set, so validation and model propagation apply. + """ + return MappingProxyType(self._channels) + + @property + def available_channels(self) -> list[PolarizationChannel]: + """The measured channels, in canonical order (pp, pm, mp, mm).""" + return list(self._channels.keys()) + + def set_channel(self, channel: Union[PolarizationChannel, str], dataset: DataSet1D) -> None: + """Add or replace one channel dataset. + + The dataset is validated, adopts the shared model, and the canonical + channel order is preserved. + + Parameters + ---------- + channel : Union[PolarizationChannel, str] + One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member). + dataset : DataSet1D + The channel data. + """ + channel = PolarizationChannel(channel) + dataset = self._validated_dataset(channel, dataset) + dataset.model = self._model + merged = dict(self._channels) + merged[channel] = dataset + self._channels = self._in_canonical_order(merged) + + def remove_channel(self, channel: Union[PolarizationChannel, str]) -> None: + """Remove one channel dataset; the last remaining channel cannot be removed. + + Parameters + ---------- + channel : Union[PolarizationChannel, str] + One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member). + """ + channel = PolarizationChannel(channel) + if channel not in self._channels: + raise ValueError(f"No '{channel.value}' channel in this dataset.") + if len(self._channels) == 1: + raise ValueError('A PolarizedDataSet requires at least one channel dataset.') + del self._channels[channel] + + def __getitem__(self, channel: Union[PolarizationChannel, str]) -> DataSet1D: + return self._channels[PolarizationChannel(channel)] + + def __contains__(self, channel: Union[PolarizationChannel, str]) -> bool: + try: + return PolarizationChannel(channel) in self._channels + except ValueError: + return False + + def __len__(self) -> int: + return len(self._channels) + + @property + def is_experiment(self) -> bool: + """Is experiment.""" + return self._model is not None + + @property + def is_simulation(self) -> bool: + """Is simulation.""" + return self._model is None + + def __repr__(self) -> str: + channel_names = ', '.join(channel.value for channel in self._channels) + return f"Polarized dataset '{self.name}' with channels: {channel_names}" + + +def detect_polarization_channel(path: str) -> Optional[PolarizationChannel]: + """Detect the spin channel of a data file. + + Tries the ORSO header (`data_source.measurement.instrument_settings.polarization`) + first, then filename heuristics ('_uu'/'_pp'/'_up' → pp, '_dd'/'_mm'/'_down' → mm, + '_ud'/'_pm' → pm, '_du'/'_mp' → mp, ...). Returns None when neither yields a + channel; a GUI should then ask the user to assign one. + + Parameters + ---------- + path : str + Path to the data file. + + Returns + ------- + Optional[PolarizationChannel] + The detected channel, or None. + """ + header_declared, channel = _channel_from_orso_header(path) + if channel is not None: + return channel + if header_declared: + # The header explicitly declares a non-channel polarization (e.g. + # 'unpolarized'): trust it over any channel-looking filename tokens. + return None + return _channel_from_filename(path) + + +def _channel_from_orso_header(path: str) -> tuple[bool, Optional[PolarizationChannel]]: + """Read the polarization of the first dataset in an ORSO file. + + Returns + ------- + tuple[bool, Optional[PolarizationChannel]] + (header declares a polarization, mapped channel or None). The flag is + False when the file is unreadable or carries no polarization field. + """ + try: + from orsopy.fileio import orso + + orso_data = orso.load_orso(str(path)) + polarization = orso_data[0].info.data_source.measurement.instrument_settings.polarization + except Exception: + return False, None + if polarization is None: + return False, None + value = getattr(polarization, 'value', polarization) + return True, _ORSO_POLARIZATION_TO_CHANNEL.get(str(value).lower()) + + +def _channel_from_filename(path: str) -> Optional[PolarizationChannel]: + """Guess the spin channel from separator-delimited tokens in the file name.""" + basename = os.path.splitext(os.path.basename(str(path)))[0].lower() + tokens = [token for token in re.split(r'[^a-z0-9]+', basename) if token] + + # Prefer the most specific match, scanning from the end (suffix convention). + for token in reversed(tokens): + if token in _TOKEN_TO_CHANNEL: + return _TOKEN_TO_CHANNEL[token] + for first, second in reversed(list(zip(tokens, tokens[1:]))): + if (first, second) in _TOKEN_PAIR_TO_CHANNEL: + return _TOKEN_PAIR_TO_CHANNEL[(first, second)] + for token in reversed(tokens): + if token in _SINGLE_TOKEN_TO_CHANNEL: + return _SINGLE_TOKEN_TO_CHANNEL[token] + return None diff --git a/src/easyreflectometry/fitting.py b/src/easyreflectometry/fitting.py index 0efaaaa5..37ba637e 100644 --- a/src/easyreflectometry/fitting.py +++ b/src/easyreflectometry/fitting.py @@ -14,6 +14,7 @@ from easyscience.fitting.multi_fitter import MultiFitter as EasyScienceMultiFitter from easyreflectometry.data import DataSet1D +from easyreflectometry.data import PolarizedDataSet from easyreflectometry.model import Model _VALID_OBJECTIVES = ('legacy_mask', 'mighell', 'hybrid', 'auto') @@ -161,6 +162,93 @@ def _fit_result_reduced_chi(result: FitResults, n_points: int | None = None) -> raise AttributeError('FitResults object has neither reduced_chi nor reduced_chi2') +def _bind_fit_func(func: Callable, unique_name: str) -> Callable: + """Bind a model's fit function to its ``unique_name`` for ``EasyScienceMultiFitter``. + + ``EasyScienceMultiFitter`` calls each fit function positionally as + ``func(x, *extra_args)``; the model's ``interface.fit_func`` expects its + ``unique_name`` as that extra positional argument, so it has to be closed + over here rather than passed through the fitter's call signature. + """ + + def wrapped(*args, **kwargs): + return func(*args, unique_name, **kwargs) + + return wrapped + + +def _emit_array_prep_warnings(stats: dict, y_vals: np.ndarray, label: str, *, action: str = 'fitting', extra: str = '') -> None: + """Warn about zero-variance handling applied by :func:`_prepare_fit_arrays`. + + Parameters + ---------- + stats : dict + The ``stats`` dict returned by :func:`_prepare_fit_arrays`. + y_vals : np.ndarray + The original (pre-transform) y values, used for the "all points" count. + label : str + Identifies what was fitted/sampled, e.g. ``'reflectivity 1'`` or + ``'channel pp'``. + action : str, optional + Verb describing the operation, e.g. ``'fitting'`` or ``'sampling'``. By default, 'fitting'. + extra : str, optional + Extra sentence(s) appended to the Mighell-related warnings (e.g. a + likelihood-validity caveat for MCMC). By default, ''. + """ + if stats['masked'] > 0: + warnings.warn( + f'Masked {stats["masked"]} data point(s) in {label} due to zero variance during {action}.', + UserWarning, + ) + if stats.get('transformed_all_points'): + warnings.warn( + f'Applied Mighell transform to all {len(y_vals)} point(s) in {label} during {action}.{extra}', + UserWarning, + ) + elif stats['mighell_substituted'] > 0: + warnings.warn( + f'Applied Mighell substitution to {stats["mighell_substituted"]} ' + f'zero-variance point(s) in {label} during {action}.{extra}', + UserWarning, + ) + + +def _classical_metrics_for(original: dict, model_curve: np.ndarray, result: FitResults, n_points: int | None = None) -> dict: + """Assemble the classical (positive-variance-only) and objective-space fit metrics. + + Parameters + ---------- + original : dict + Dict with keys ``'y'`` and ``'variances'`` holding the un-transformed + observed values and their variances (σ²). + model_curve : np.ndarray + Model evaluated at the original x values. + result : FitResults + The minimizer's result for this dataset/channel. + n_points : int | None, optional + Number of points actually fitted, used as the ``reduced_chi``/``reduced_chi2`` + fallback's point count. If ``None``, derived from ``result.x``. By default, None. + + Returns + ------- + dict + Keys ``'classical_chi2'``, ``'classical_reduced_chi'``, + ``'objective_chi2'``, ``'objective_reduced_chi'``, ``'n_classical_points'``. + """ + sigma_classical = np.sqrt(np.clip(original['variances'], 0.0, None)) + n_classical_points = int(np.sum(original['variances'] > 0.0)) + classical_chi2 = _compute_weighted_chi2(original['y'], model_curve, sigma_classical) + if n_points is None: + n_points = np.size(result.x) + return { + 'classical_chi2': classical_chi2, + 'classical_reduced_chi': _compute_reduced_chi2(classical_chi2, n_classical_points, result.n_pars), + 'objective_chi2': float(result.chi2), + 'objective_reduced_chi': _fit_result_reduced_chi(result, n_points), + 'n_classical_points': n_classical_points, + } + + class MultiFitter: def __init__(self, *args: Model, objective: str = 'hybrid'): r"""A convenience class for the :py:class:`easyscience.Fitting.Fitting` @@ -179,23 +267,100 @@ def __init__(self, *args: Model, objective: str = 'hybrid'): ``'auto'`` (alias for ``'hybrid'``). By default, 'hybrid'. """ - # This lets the unique_name be passed with the fit_func. - def func_wrapper(func, unique_name): - """Func wrapper.""" - - def wrapped(*args, **kwargs): - """Wrapped function.""" - return func(*args, unique_name, **kwargs) - - return wrapped - - self._fit_func = [func_wrapper(m.interface.fit_func, m.unique_name) for m in args] + self._fit_func = [_bind_fit_func(m.interface.fit_func, m.unique_name) for m in args] self._models = args self.easy_science_multi_fitter = EasyScienceMultiFitter(args, self._fit_func) self._fit_results: list[FitResults] | None = None self._classical_fit_metrics: list[dict] | None = None self._objective = _validate_objective(objective) self._sampler: Sampler | None = None + # Set by `for_experiments`: the datasets the fit functions correspond + # to, and the spin channel each one is evaluated on (None = unpolarized). + self.fit_datasets: list[DataSet1D] = [] + self.fit_channels: list[Any] = [] + + @classmethod + def for_experiments( + cls, + experiments: list[DataSet1D | PolarizedDataSet], + objective: str = 'hybrid', + ) -> 'MultiFitter': + """Build a fitter for a mixed list of unpolarized and polarized experiments. + + Every experiment contributes one fit function per dataset: an ordinary + experiment one, a polarized experiment one per measured spin channel, + each evaluating that channel's spin cross-section against the single + model the channels share. Structural parameters are therefore common to + all channels and the magnetic ones are constrained by all of them at + once, exactly as in :meth:`fit_polarized` — but here several experiments + (and several models) can be fitted together, which is what an + application's "fit everything that is loaded" action needs. + + The resulting fitter is *not* run: the caller supplies the data arrays + to ``easy_science_multi_fitter.fit(...)`` in the order given by + :attr:`fit_datasets`, which lets a GUI drive it from a worker thread. + + Note + ---- + Built via ``cls(*models, objective=objective)`` and then overwrites + ``_fit_func`` / ``easy_science_multi_fitter`` with the per-channel + versions — the ``__init__``-built pair is briefly constructed and + discarded. Unlike :meth:`fit_polarized`, the minimizer selection and + its ``tolerance`` / ``max_evaluations`` are *not* carried over: the + returned fitter starts from ``easy_science_multi_fitter``'s defaults, + so a caller that needs a specific minimizer must set it explicitly + before calling ``.fit(...)``. + + Parameters + ---------- + experiments : list[DataSet1D | PolarizedDataSet] + The loaded experiments, in the order they should be fitted. + objective : str, optional + Zero-variance handling strategy, see :meth:`__init__`. By default, 'hybrid'. + + Returns + ------- + MultiFitter + Fitter whose ``easy_science_multi_fitter`` has one fit function per + entry of ``fit_datasets``, with ``fit_channels`` holding the + matching :class:`PolarizationChannel` (``None`` when unpolarized). + """ + if not experiments: + raise ValueError('At least one experiment is required to build a fitter.') + + models: list[Model] = [] + datasets: list[DataSet1D] = [] + channels: list[Any] = [] + for experiment in experiments: + model = experiment.model + if model is None: + raise ValueError(f"Experiment '{getattr(experiment, 'name', experiment)}' has no model to fit.") + # `in` would compare models by value; identity is what matters here. + if not any(model is known for known in models): + models.append(model) + experiment_channels = getattr(experiment, 'available_channels', None) + if experiment_channels is None: + datasets.append(experiment) + channels.append(None) + continue + for channel in experiment_channels: + datasets.append(experiment[channel]) + channels.append(channel) + + fitter = cls(*models, objective=objective) + + fit_funcs = [] + for dataset, channel in zip(datasets, channels): + model = dataset.model + interface = model.interface + func = interface.fit_func if channel is None else interface.fit_func_for_channel(channel) + fit_funcs.append(_bind_fit_func(func, model.unique_name)) + + fitter._fit_func = fit_funcs + fitter.easy_science_multi_fitter = EasyScienceMultiFitter(models, fit_funcs) + fitter.fit_datasets = datasets + fitter.fit_channels = channels + return fitter def fit(self, data: sc.DataGroup, id: int = 0, objective: str | None = None) -> sc.DataGroup: """Perform the fitting and populate the DataGroups with the result. @@ -230,23 +395,7 @@ def fit(self, data: sc.DataGroup, id: int = 0, objective: str | None = None) -> variances = data['data'][f'R_{i}'].variances x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj) - - if stats['masked'] > 0: - warnings.warn( - f'Masked {stats["masked"]} data point(s) in reflectivity {i} due to zero variance during fitting.', - UserWarning, - ) - if stats.get('transformed_all_points'): - warnings.warn( - f'Applied Mighell transform to all {len(y_vals)} point(s) in reflectivity {i} during fitting.', - UserWarning, - ) - elif stats['mighell_substituted'] > 0: - warnings.warn( - f'Applied Mighell substitution to {stats["mighell_substituted"]} ' - f'zero-variance point(s) in reflectivity {i} during fitting.', - UserWarning, - ) + _emit_array_prep_warnings(stats, y_vals, f'reflectivity {i}') x.append(x_out) y.append(y_eff) @@ -270,27 +419,14 @@ def fit(self, data: sc.DataGroup, id: int = 0, objective: str | None = None) -> values=sld_profile[0], unit=(1 / new_data['coords'][f'Qz_{id}'].unit).unit, ) - original = original_arrays[i] - sigma_classical = np.sqrt(np.clip(original['variances'], 0.0, None)) - n_classical_points = int(np.sum(original['variances'] > 0.0)) - classical_chi2 = _compute_weighted_chi2(original['y'], model_curve, sigma_classical) - classical_reduced_chi = _compute_reduced_chi2(classical_chi2, n_classical_points, result[i].n_pars) - objective_chi2 = float(result[i].chi2) - objective_reduced_chi = _fit_result_reduced_chi(result[i], np.size(result[i].x)) - - self._classical_fit_metrics.append({ - 'classical_chi2': classical_chi2, - 'classical_reduced_chi': classical_reduced_chi, - 'objective_chi2': objective_chi2, - 'objective_reduced_chi': objective_reduced_chi, - 'n_classical_points': n_classical_points, - }) - - new_data['objective_chi2'] = objective_chi2 - new_data['objective_reduced_chi'] = objective_reduced_chi - new_data['classical_chi2'] = classical_chi2 - new_data['classical_reduced_chi'] = classical_reduced_chi - new_data['reduced_chi'] = objective_reduced_chi + metrics = _classical_metrics_for(original_arrays[i], model_curve, result[i]) + self._classical_fit_metrics.append(metrics) + + new_data['objective_chi2'] = metrics['objective_chi2'] + new_data['objective_reduced_chi'] = metrics['objective_reduced_chi'] + new_data['classical_chi2'] = metrics['classical_chi2'] + new_data['classical_reduced_chi'] = metrics['classical_reduced_chi'] + new_data['reduced_chi'] = metrics['objective_reduced_chi'] new_data['success'] = result[i].success return new_data @@ -318,45 +454,111 @@ def fit_single_data_set_1d(self, data: DataSet1D, objective: str | None = None) variances = np.asarray(data.ye) x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj) - - if stats['masked'] > 0: - warnings.warn( - f'Masked {stats["masked"]} data point(s) in single-dataset fit due to zero variance during fitting.', - UserWarning, - ) - if stats.get('transformed_all_points'): - warnings.warn( - f'Applied Mighell transform to all {len(y_vals)} point(s) in single-dataset fit during fitting.', - UserWarning, - ) - elif stats['mighell_substituted'] > 0: - warnings.warn( - f'Applied Mighell substitution to {stats["mighell_substituted"]} ' - 'zero-variance point(s) in single-dataset fit during fitting.', - UserWarning, - ) + _emit_array_prep_warnings(stats, y_vals, 'single-dataset fit') if obj == 'legacy_mask' and len(x_out) == 0: raise ValueError('Cannot fit single dataset: all points have zero variance.') result = self.easy_science_multi_fitter.fit(x=[x_out], y=[y_eff], weights=[weights])[0] self._fit_results = [result] - sigma_classical = np.sqrt(np.clip(variances, 0.0, None)) model_curve = self._fit_func[0](x_vals) - n_classical_points = int(np.sum(variances > 0.0)) - classical_chi2 = _compute_weighted_chi2(y_vals, model_curve, sigma_classical) - classical_reduced_chi = _compute_reduced_chi2(classical_chi2, n_classical_points, result.n_pars) self._classical_fit_metrics = [ - { - 'classical_chi2': classical_chi2, - 'classical_reduced_chi': classical_reduced_chi, - 'objective_chi2': float(result.chi2), - 'objective_reduced_chi': _fit_result_reduced_chi(result, len(x_out)), - 'n_classical_points': n_classical_points, - } + _classical_metrics_for({'y': y_vals, 'variances': variances}, model_curve, result, n_points=len(x_out)) ] return result + def fit_polarized(self, data: PolarizedDataSet, objective: str | None = None) -> dict[str, FitResults]: + """Fit all measured spin channels of a polarized experiment simultaneously. + + Each channel dataset gets its own fit function evaluating the + corresponding spin cross-section, while every channel shares the single + model — so structural parameters (thickness, roughness, nuclear SLD, + scale, background) are common, and the magnetic parameters + (`rho_m`/`theta_m`) are constrained by all channels at once. The refl1d + backend computes all four cross-sections in one kernel evaluation and + caches them per iteration, so fitting N channels costs about as much as + fitting one. + + Parameters + ---------- + data : PolarizedDataSet + The polarized experiment (its model must be the model this fitter + was constructed with). Note that per-channel ``ye`` stores + variances (σ²), not standard deviations. + objective : str | None, optional + Per-call override for the zero-variance objective. + If ``None``, uses the instance default set at construction. By default, None. + + Returns + ------- + dict[str, FitResults] + Fit results per channel, keyed 'pp', 'pm', 'mp', 'mm' (measured + channels only, in that order). + + Note + ---- + Unlike :meth:`for_experiments`, this method does not populate + :attr:`fit_datasets` / :attr:`fit_channels` — those are set only by the + caller-driven, `for_experiments`-built flow. + """ + obj = _validate_objective(objective) if objective is not None else self._objective + if len(self._models) != 1: + raise ValueError('Polarized fitting requires a MultiFitter constructed with exactly one model.') + model = self._models[0] + if data.model is not model: + raise ValueError('PolarizedDataSet.model must be the model this fitter was constructed with.') + channels = data.available_channels + for channel in channels: + if data[channel].model is not model: + raise ValueError(f"The '{channel.value}' channel dataset is bound to a different model than the fitter's.") + + channel_fit_funcs = [ + _bind_fit_func(model.interface.fit_func_for_channel(channel), model.unique_name) for channel in channels + ] + # One fit function per channel, all bound to the single model. Constructed + # per call because the channel set comes from the data; the minimizer + # selection and its generic settings (tolerance, max_evaluations) are + # carried over. Engine-specific settings applied directly to the minimizer + # instance of this fitter would not be. + polarized_fitter = EasyScienceMultiFitter([model], channel_fit_funcs) + polarized_fitter.switch_minimizer(self.easy_science_multi_fitter.minimizer.enum) + polarized_fitter.tolerance = self.easy_science_multi_fitter.tolerance + polarized_fitter.max_evaluations = self.easy_science_multi_fitter.max_evaluations + + x = [] + y = [] + dy = [] + original_arrays = [] + for channel in channels: + dataset = data[channel] + x_vals = np.asarray(dataset.x) + y_vals = np.asarray(dataset.y) + variances = np.asarray(dataset.ye) + + x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj) + _emit_array_prep_warnings(stats, y_vals, f'channel {channel.value}') + if obj == 'legacy_mask' and len(x_out) == 0: + raise ValueError(f'Cannot fit channel {channel.value}: all points have zero variance.') + + x.append(x_out) + y.append(y_eff) + dy.append(weights) + original_arrays.append({'x': x_vals, 'y': y_vals, 'variances': variances}) + + results = polarized_fitter.fit(x, y, weights=dy) + # All channels are fitted against one parameter vector (the shared model), + # so `result.n_pars` is identical across `results`; `reduced_chi` and + # `classical_reduced_chi` below rely on that invariant. + self._fit_results = list(results) + + self._classical_fit_metrics = [] + for index, (channel, result) in enumerate(zip(channels, results)): + original = original_arrays[index] + model_curve = channel_fit_funcs[index](original['x']) + self._classical_fit_metrics.append(_classical_metrics_for(original, model_curve, result)) + + return {channel.value: result for channel, result in zip(channels, results)} + def mcmc_sample( self, data: sc.DataGroup, @@ -425,27 +627,16 @@ def mcmc_sample( ) x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj) - - if stats['masked'] > 0: - warnings.warn( - f'Masked {stats["masked"]} data point(s) in reflectivity {i} due to zero variance during sampling.', - UserWarning, - ) - if stats.get('transformed_all_points'): - warnings.warn( - f'Applied Mighell transform to all {len(y_vals)} point(s) in reflectivity {i} during sampling. ' - 'The Mighell transform is a chi-square bias correction, not a true likelihood; ' - 'posterior widths may be unreliable.', - UserWarning, - ) - elif stats['mighell_substituted'] > 0: - warnings.warn( - f'Applied Mighell substitution to {stats["mighell_substituted"]} ' - f'zero-variance point(s) in reflectivity {i} during sampling. ' - 'The Mighell transform is a chi-square bias correction, not a true likelihood; ' - 'posterior widths may be unreliable.', - UserWarning, - ) + _emit_array_prep_warnings( + stats, + y_vals, + f'reflectivity {i}', + action='sampling', + extra=( + ' The Mighell transform is a chi-square bias correction, not a true likelihood; ' + 'posterior widths may be unreliable.' + ), + ) x.append(x_out) y.append(y_eff) dy.append(weights) @@ -542,6 +733,27 @@ def objective_reduced_chi(self) -> float | None: """Objective-space reduced chi-squared returned by the minimizer.""" return self.reduced_chi + def record_fit_results(self, results: list[FitResults] | None) -> None: + """Adopt fit results produced elsewhere, so this fitter reports on them. + + An application that drives ``easy_science_multi_fitter.fit(...)`` itself + — to run it in a worker thread, for instance — leaves the ``MultiFitter`` + that owns the goodness-of-fit properties none the wiser. Handing the + results back here makes :attr:`chi2` / :attr:`reduced_chi` describe that + fit instead of reporting "no fit performed". + + Only the minimizer-reported metrics are restored: the classical ones + need the original data arrays, which are not part of ``FitResults``, so + :attr:`classical_chi2` and :attr:`classical_reduced_chi` stay None. + + Parameters + ---------- + results : list[FitResults] | None + Results of the fit, one per fitted dataset. None clears them. + """ + self._fit_results = list(results) if results else None + self._classical_fit_metrics = None + def switch_minimizer(self, minimizer: AvailableMinimizers) -> None: """Switch the minimizer for the fitting. diff --git a/src/easyreflectometry/limits.py b/src/easyreflectometry/limits.py index 001bba64..b41a37a1 100644 --- a/src/easyreflectometry/limits.py +++ b/src/easyreflectometry/limits.py @@ -17,14 +17,15 @@ def apply_default_limits(parameter: Parameter, kind: str) -> None: parameter : Parameter The parameter to adjust. kind : str - One of 'thickness', 'roughness', 'sld', 'isld', 'scale'. + One of 'thickness', 'roughness', 'sld', 'isld', 'rho_m', 'scale'. """ if not parameter.independent: return if kind in ('thickness', 'roughness'): _apply_percentage_limits(parameter) - elif kind in ('sld', 'isld'): + elif kind in ('sld', 'isld', 'rho_m'): + # A magnetic SLD is an SLD: same physical scale, same default window. _apply_fixed_limits(parameter, *SLD_LIMITS) elif kind == 'scale': _apply_fixed_limits(parameter, *SCALE_LIMITS) diff --git a/src/easyreflectometry/model/model.py b/src/easyreflectometry/model/model.py index da1396d0..993dc3e8 100644 --- a/src/easyreflectometry/model/model.py +++ b/src/easyreflectometry/model/model.py @@ -196,6 +196,11 @@ def remove_assembly(self, index: int) -> None: if self.interface is not None: self.interface().remove_item_from_model(assembly_unique_name, self.unique_name) + @property + def has_magnetism(self) -> bool: + """Whether any layer in the sample carries magnetic properties.""" + return any(getattr(layer, 'magnetism', None) is not None for assembly in self.sample for layer in assembly.layers) + @property def is_default(self) -> bool: """Whether this model was created as a default placeholder.""" diff --git a/src/easyreflectometry/project.py b/src/easyreflectometry/project.py index aa0eacf6..1c7fd29e 100644 --- a/src/easyreflectometry/project.py +++ b/src/easyreflectometry/project.py @@ -19,7 +19,11 @@ from scipp import DataGroup from easyreflectometry.calculators import CalculatorFactory +from easyreflectometry.calculators import PolarizationChannel +from easyreflectometry.calculators.calculator_base import CalculatorBase from easyreflectometry.data import DataSet1D +from easyreflectometry.data import PolarizedDataSet +from easyreflectometry.data import detect_polarization_channel from easyreflectometry.data import load_as_dataset from easyreflectometry.data.measurement import extract_orso_title from easyreflectometry.data.measurement import load_data_from_orso_file @@ -42,6 +46,33 @@ Q_MAX = 0.3 Q_RESOLUTION = 500 +# Guide-field angle A in degrees, used to project the moment onto the neutron +# quantisation axis for the spin-up/spin-down potentials and nothing else. +# refl1d's default (`Aguide = 270`), which is what every model the library can +# currently build uses — `Aguide` is not exposed as a parameter yet. Keep this +# the single source of the value: exposing it later is a change here only. +GUIDE_FIELD_ANGLE = 270.0 + +# Points whose spin-asymmetry denominator (R⁺⁺ + R⁻⁻) is smaller than this +# multiple of its own uncertainty are dropped: there SA is noise divided by +# noise and would swamp the axis with ±values of no physical meaning. +SPIN_ASYMMETRY_SIGNIFICANCE = 3.0 + +# Points whose spin-asymmetry denominator (R⁺⁺ + R⁻⁻) is smaller than this +# fraction of |R⁺⁺| + |R⁻⁻| are dropped too: there the sum is what is left after +# cancellation between the two channels, so SA is a ratio of rounding noise. +# Unlike the significance rule above this needs no uncertainties, which is what +# keeps a two-column file from putting ±10³ values on the axis. +SPIN_ASYMMETRY_CANCELLATION_FRACTION = 1e-3 + +# A depth whose magnetic SLD is below this fraction of the largest one in the +# profile carries no moment worth speaking of, and its moment *angle* is +# meaningless — the direction of a (nearly) zero-length vector. Used to restrict +# the reported theta_m profile; a relative floor because it has to work both for +# a weak 0.1 and a strong 5 (1e-6 A^-2) moment, and because the interface +# roughness leaves a small erf tail everywhere. +MAGNETIC_MOMENT_FLOOR_FRACTION = 0.01 + DEFAULT_MINIMIZER = AvailableMinimizers.LMFit_leastsq @@ -130,6 +161,12 @@ def _sync_parameter_states(self) -> None: for layer in assembly.layers: self._sync_layer_parameter_state(layer.thickness, 'thickness', disabled_ids) self._sync_layer_parameter_state(layer.roughness, 'roughness', disabled_ids) + magnetism = getattr(layer, 'magnetism', None) + if magnetism is not None: + # Magnetic parameters exist only on magnetic layers, so + # they are never in `disabled_ids`; theta_m carries + # explicit 0-360 bounds and needs no default window. + self._sync_layer_parameter_state(magnetism.rho_m, 'rho_m', disabled_ids) def _sync_layer_parameter_state(self, parameter: Parameter, kind: str, disabled_ids: set[int]) -> None: """Update a layer parameter's enabled state and pending default limits.""" @@ -275,7 +312,11 @@ def models(self, models: ModelCollection) -> None: self._replace_collection(models, self._models) # Use setter to update indicies for current model, assembly and layer self.current_model_index = 0 - self._materials.extend(self._get_materials_in_models()) + # Only track materials not already in the project's material collection + # (e.g. layers built from self._materials, as in default_model(), would + # otherwise be re-added and trigger a spurious duplicate-item warning). + new_materials = [material for material in self._get_materials_in_models() if material not in self._materials] + self._materials.extend(new_materials) for model in self._models: model.interface = self._calculator self._sync_parameter_states() @@ -307,9 +348,46 @@ def calculator(self, calculator: str) -> None: for model in self._models: model.generate_bindings() + # The cached fitter holds fit functions bound to the previous backend. self._fitter = None self._fitter_model_index = None + @property + def calculator_supports_magnetism(self) -> bool: + """Whether the active calculator can model magnetic samples. + + The GUI uses this to gate magnetism-related controls (e.g. when the + refnx or bornagain backend is selected). + """ + return self._calculator().supports_magnetism + + @property + def calculators_supporting_magnetism(self) -> List[str]: + """Names of the available calculators that can model magnetic samples. + + Lets an application offer the switch a magnetic sample needs ("this + requires refl1d — change to it?") instead of only reporting that the + current calculator cannot do it. The active calculator is not touched. + """ + supporting = [] + for name in self._calculator.available_interfaces: + calculator = next( + (candidate for candidate in CalculatorBase._calculators if candidate.name == name), + None, + ) + if calculator is not None and calculator().supports_magnetism: + supporting.append(name) + return supporting + + @property + def models_have_magnetism(self) -> bool: + """Whether any model in the project carries a magnetic layer. + + A calculator without magnetism cannot be selected while this holds — the + binding it would have to build does not exist. + """ + return any(model.has_magnetism for model in self._models) + @property def minimizer(self) -> AvailableMinimizers: """Minimizer function.""" @@ -333,12 +411,12 @@ def minimizer(self, minimizer: AvailableMinimizers) -> None: self._fitter.easy_science_multi_fitter.switch_minimizer(minimizer) @property - def experiments(self) -> Dict[int, DataSet1D]: + def experiments(self) -> Dict[int, Union[DataSet1D, PolarizedDataSet]]: """Experiments function.""" return self._experiments @experiments.setter - def experiments(self, experiments: Dict[int, DataSet1D]) -> None: + def experiments(self, experiments: Dict[int, Union[DataSet1D, PolarizedDataSet]]) -> None: """Experiments function.""" self._experiments = experiments @@ -638,6 +716,92 @@ def load_all_experiments_from_file(self, path: Union[Path, str]) -> int: self._with_experiments = True return len(data_keys) + def suggest_polarized_channel_assignment(self, paths: List[Union[Path, str]]) -> Dict[str, Optional[PolarizationChannel]]: + """Suggest a spin-channel assignment for a set of data files. + + For each file, the ORSO header polarization is used when present, falling + back to filename heuristics ('_uu'/'_up' → pp, '_dd'/'_down' → mm, ...). + Files that cannot be identified map to None; a GUI should present the + result as an editable file → channel table. + + Parameters + ---------- + paths : List[Union[Path, str]] + Paths of the per-channel data files. + + Returns + ------- + Dict[str, Optional[PolarizationChannel]] + Detected channel (or None) per path. + """ + return {str(path): detect_polarization_channel(str(path)) for path in paths} + + def load_polarized_experiment( + self, + paths: Dict[Union[PolarizationChannel, str], Union[Path, str]], + model_index: Optional[int] = None, + ) -> int: + """Load a polarized experiment from one data file per spin channel. + + The channel datasets form a single :class:`PolarizedDataSet` experiment + sharing one model. Two-channel NSF-only experiments simply pass 'pp' and + 'mm' entries; spin-flip channels are optional. + + Parameters + ---------- + paths : Dict[Union[PolarizationChannel, str], Union[Path, str]] + Explicit channel → file assignment (e.g. from the GUI import dialog, + pre-filled via :meth:`suggest_polarized_channel_assignment`). + model_index : Optional[int], optional + Index of the model the experiment belongs to. By default, the + current model. + + Returns + ------- + int + Index of the newly loaded experiment, so a caller can make it + current. + """ + paths = {PolarizationChannel(channel): path for channel, path in paths.items()} + channels = {} + for channel, path in paths.items(): + # One file per channel means one dataset per file; a multi-dataset ORSO + # file has no defined channel-to-dataset assignment here. + if self.count_datasets_in_file(path) > 1: + raise ValueError( + f"File '{path}' contains multiple datasets; polarized loading requires one dataset " + f'per channel file. Export the {channel.value} channel to its own file.' + ) + dataset = load_as_dataset(str(path)) + # Keep the source file visible per channel (file → channel provenance). + dataset.name = f'{channel.value}: {Path(path).name}' + channels[channel] = dataset + + new_index = len(self._experiments) + if model_index is None: + model_index = self._current_model_index + model = self.models[model_index] + + experiment = PolarizedDataSet( + name=f'Polarized experiment {new_index}', + channels=channels, + model=model, + ) + # Name from the ORSO title of the first file, when available. + first_channel = experiment.available_channels[0] + first_path = paths[first_channel] + self._apply_experiment_metadata(first_path, experiment, f'Polarized experiment {new_index}') + + # Background and resolution follow the first (in canonical order) channel; + # per-channel resolution functions are not supported (one per experiment). + first_dataset = experiment[first_channel] + self._auto_set_background(first_dataset) + self._apply_resolution_function(first_dataset, model) + + self._experiments[new_index] = experiment + self._with_experiments = True + return new_index + def load_experiment_for_model_at_index(self, path: Union[Path, str], index: Optional[int] = 0) -> None: """Load experiment for model at index.""" experiment = load_as_dataset(str(path)) @@ -649,9 +813,21 @@ def load_experiment_for_model_at_index(self, path: Union[Path, str], index: Opti self._with_experiments = True self._apply_resolution_function(experiment, self._models[index]) + def _bind_calculator(self, model) -> None: + """Bind the project's calculator to a model unless it already is. + + Reassigning ``model.interface`` re-propagates the interface over the + whole sample tree — an expensive rebuild. The plot getters run on every + chart refresh, so an already-bound model must be left alone; engine + switches go through the ``calculator`` setter, which regenerates the + bindings itself. + """ + if model.interface is not self._calculator: + model.interface = self._calculator + def sld_data_for_model_at_index(self, index: int = 0) -> DataSet1D: """Sld data for model at index.""" - self.models[index].interface = self._calculator + self._bind_calculator(self.models[index]) sld = self.models[index].interface().sld_profile(self._models[index].unique_name) return DataSet1D( name=f'SLD for Model {index}', @@ -659,6 +835,74 @@ def sld_data_for_model_at_index(self, index: int = 0) -> DataSet1D: y=sld[1], ) + def model_has_magnetism_at_index(self, index: int = 0) -> bool: + """Whether the model at index carries magnetism (False when there is no such model).""" + try: + return bool(self.models[index].has_magnetism) + except (IndexError, AttributeError): + return False + + def magnetic_sld_data_for_model_at_index(self, index: int = 0) -> Dict[str, DataSet1D]: + """Nuclear and magnetic depth profiles of a magnetic model. + + Parameters + ---------- + index : int + Index of the model. + + Returns + ------- + Dict[str, DataSet1D] + Profiles versus depth z, keyed: + + - ``'sld'``: nuclear ρ(z), the same curve as + :meth:`sld_data_for_model_at_index`; + - ``'rho_m'``: magnetic SLD ρM(z); + - ``'theta_m'``: in-plane moment angle θM(z) in degrees, restricted + to the depths that carry a moment — the angle of a zero-length + vector is meaningless, so points below + :data:`MAGNETIC_MOMENT_FLOOR_FRACTION` of the largest ρM are left + out instead of drawing an arbitrary angle through vacuum; + - ``'spin_up'`` / ``'spin_down'``: the potentials each spin state + sees, ρ(z) ± ρM(z)·cos(θM(z) − A), where A is the guide-field + angle :data:`GUIDE_FIELD_ANGLE`. + + Raises + ------ + ValueError + The model has no magnetic layer, so there is no magnetic profile. + NotImplementedError + The active calculator cannot model magnetism. + """ + model = self.models[index] + if not model.has_magnetism: + raise ValueError( + f'Model {index} has no magnetic layer; there is no magnetic SLD profile to show. ' + 'Attach magnetism to a layer first.' + ) + self._bind_calculator(model) + z, sld, rho_m, theta_m = model.interface().magnetic_sld_profile(model.unique_name) + z = np.asarray(z, dtype=float) + sld = np.asarray(sld, dtype=float) + rho_m = np.asarray(rho_m, dtype=float) + theta_m = np.asarray(theta_m, dtype=float) + # Component of the moment along the guide field: what the neutron spin + # states add to / subtract from the nuclear potential. + projection = rho_m * np.cos(np.radians(theta_m - GUIDE_FIELD_ANGLE)) + # Only report the angle where there is a moment to have an angle, and + # make it continuous along z: 359° followed by 1° is a 2° turn, but a + # plotted line through the wrapped values sweeps the whole circle. + magnitude = np.abs(rho_m) + has_moment = magnitude > MAGNETIC_MOMENT_FLOOR_FRACTION * magnitude.max(initial=0.0) + theta_display = _unwrapped_angle(theta_m, has_moment) + return { + 'sld': DataSet1D(name=f'SLD for Model {index}', x=z, y=sld), + 'rho_m': DataSet1D(name=f'Magnetic SLD for Model {index}', x=z, y=rho_m), + 'theta_m': DataSet1D(name=f'Moment angle for Model {index}', x=z[has_moment], y=theta_display[has_moment]), + 'spin_up': DataSet1D(name=f'Spin-up potential for Model {index}', x=z, y=sld + projection), + 'spin_down': DataSet1D(name=f'Spin-down potential for Model {index}', x=z, y=sld - projection), + } + def sample_data_for_model_at_index(self, index: int = 0, q_range: Optional[np.array] = None) -> DataSet1D: """Sample data for model at index.""" original_resolution_function = self.models[index].resolution_function @@ -668,24 +912,268 @@ def sample_data_for_model_at_index(self, index: int = 0, q_range: Optional[np.ar return reflectivity_data - def model_data_for_model_at_index(self, index: int = 0, q_range: Optional[np.array] = None) -> DataSet1D: - """Model data for model at index.""" + def model_data_for_model_at_index( + self, + index: int = 0, + q_range: Optional[np.array] = None, + channel: Optional[Union[PolarizationChannel, str]] = None, + ) -> DataSet1D: + """Model data for model at index. + + Parameters + ---------- + index : int + Index of the model. + q_range : Optional[np.array] + Points to calculate at; the project q range by default. + channel : Optional[Union[PolarizationChannel, str]] + Spin cross-section to calculate. `None` (the default) gives the + ordinary, channel-agnostic reflectivity. A channel requires a + magnetic model (except 'pp', which falls back to the unpolarized + calculation) and a calculator supporting magnetism, otherwise the + calculator raises. + """ if q_range is None: q_range = np.linspace(self.q_min, self.q_max, self.q_resolution) - self.models[index].interface = self._calculator - reflectivity = self.models[index].interface().reflectity_profile(q_range, self._models[index].unique_name) + self._bind_calculator(self.models[index]) + if channel is None: + reflectivity = self.models[index].interface().reflectity_profile(q_range, self._models[index].unique_name) + name = f'Reflectivity for Model {index}' + else: + channel = PolarizationChannel(channel) + reflectivity = ( + self.models[index].interface().reflectivity_profile_channel(q_range, self._models[index].unique_name, channel) + ) + name = f'Reflectivity ({channel.value}) for Model {index}' return DataSet1D( - name=f'Reflectivity for Model {index}', + name=name, x=q_range, y=reflectivity, ) - def experimental_data_for_model_at_index(self, index: int = 0) -> DataSet1D: - """Experimental data for model at index.""" - if index in self._experiments.keys(): - return self._experiments[index] - else: + def experimental_data_for_model_at_index( + self, + index: int = 0, + channel: Optional[Union[PolarizationChannel, str]] = None, + ) -> Union[DataSet1D, PolarizedDataSet]: + """Experimental data for model at index. + + Parameters + ---------- + index : int + Index of the experiment. + channel : Optional[Union[PolarizationChannel, str]] + Spin channel of a polarized experiment. `None` (the default) keeps + the historical behavior and returns the stored experiment as is: a + `DataSet1D` for an unpolarized experiment, the whole + `PolarizedDataSet` for a polarized one. + + Returns + ------- + Union[DataSet1D, PolarizedDataSet] + The experiment, or the `DataSet1D` of the requested channel. + + Raises + ------ + IndexError + No experiment is loaded at `index`. + KeyError + `channel` is a valid spin channel but was not measured. + ValueError + `channel` was given for an unpolarized experiment, or is not one of + 'pp', 'pm', 'mp', 'mm'. + """ + if index not in self._experiments.keys(): + raise IndexError(f'No experiment data for model at index {index}') + + experiment = self._experiments[index] + if channel is None: + return experiment + + if not isinstance(experiment, PolarizedDataSet): + raise ValueError( + f"Experiment at index {index} is not polarized; it has no '{channel}' channel. " + 'Call without a channel argument to get its data.' + ) + try: + channel = PolarizationChannel(channel) + except ValueError as exception: + known = ', '.join(member.value for member in PolarizationChannel) + raise ValueError(f"Unknown spin channel '{channel}'; expected one of {known}.") from exception + if channel not in experiment: + measured = ', '.join(member.value for member in experiment.available_channels) + raise KeyError(f"Channel '{channel.value}' was not measured in experiment {index} (measured: {measured}).") + return experiment[channel] + + def experiment_is_polarized_at_index(self, index: int = 0) -> bool: + """Whether the experiment at index holds per-channel (polarized) data. + + Returns False when no experiment is loaded at `index`, so consumers can + use it as a plain predicate. + """ + return isinstance(self._experiments.get(index), PolarizedDataSet) + + def experiment_channels_at_index(self, index: int = 0) -> List[PolarizationChannel]: + """Measured spin channels of the experiment at index ([] when unpolarized).""" + experiment = self._experiments.get(index) + if not isinstance(experiment, PolarizedDataSet): + return [] + return experiment.available_channels + + def experiment_supports_spin_asymmetry_at_index(self, index: int = 0) -> bool: + """Whether SA can be formed for the experiment at index. + + Spin asymmetry needs both non-spin-flip channels; an NSF-incomplete or + spin-flip-only experiment has no SA. The two channel datasets must also + be structurally usable (non-empty, matching lengths, a strictly ordered + q grid) — an application asks this before offering a spin-asymmetry + view, so a dataset that cannot be turned into one must not be advertised + and then fail. + """ + channels = self.experiment_channels_at_index(index) + if not _has_both_nsf_channels(channels): + return False + experiment = self._experiments[index] + try: + for channel in (PolarizationChannel.PP, PolarizationChannel.MM): + _ordered_channel_arrays(experiment[channel]) + except ValueError as exception: + logger.warning('Experiment %s cannot produce a spin asymmetry: %s', index, exception) + return False + return True + + def spin_asymmetry_for_experiment_at_index(self, index: int = 0) -> Dict[str, object]: + """Spin asymmetry SA = (R⁺⁺ − R⁻⁻) / (R⁺⁺ + R⁻⁻) of a polarized experiment. + + The measured SA is formed on the q grid of the pp channel. A mm channel + measured on a different grid is linearly interpolated onto it — values + with the usual weights, variances with the *squared* weights, which is + the propagation rule for independent endpoints (the covariance this + introduces between neighbouring SA points is not representable in + `DataSet1D` and is discarded). Interpolation happens **only inside the q + range both channels cover**: `np.interp` would otherwise clamp to the + edge value and turn extrapolated points into fabricated measurements. + + Points are dropped when the denominator cannot carry a meaningful + asymmetry: + + - it is not positive, or is lost to cancellation between the two + channels (see :data:`SPIN_ASYMMETRY_CANCELLATION_FRACTION`) — this + guard needs no uncertainties and is what keeps a background-subtracted + tail from throwing ±10³ values onto the axis; + - it is not above :data:`SPIN_ASYMMETRY_SIGNIFICANCE` times its own + uncertainty, when the channels carry usable uncertainties; + - the point itself is not usable (non-finite reflectivity, or a negative + or non-finite variance). + + Parameters + ---------- + index : int + Index of the experiment. + + Returns + ------- + Dict[str, object] + - ``'measured'``: `DataSet1D` of SA versus q. As everywhere in this + library, ``ye`` holds **variances**, not standard deviations. + - ``'calculated'``: `DataSet1D` of the model SA on the same q + points, or None when the model is not magnetic (an unpolarized + model has SA ≡ 0, which is not worth drawing). + - ``'masked_points'``: how many measured points were dropped for any + of the reasons above. + - ``'low_significance_points'``: of those, how many had a + denominator below the significance threshold. + - ``'small_denominator_points'``: of those, how many had a + denominator that was non-positive or lost to cancellation. + - ``'invalid_points'``: of those, how many carried a non-finite + reflectivity or an unusable variance. + - ``'out_of_overlap_points'``: number of pp points dropped because + the mm channel does not cover their q. + + Raises + ------ + IndexError + No experiment is loaded at `index`. + ValueError + The experiment does not carry both pp and mm channels, or their + datasets are not structurally usable. + """ + if index not in self._experiments.keys(): raise IndexError(f'No experiment data for model at index {index}') + channels = self.experiment_channels_at_index(index) + if not _has_both_nsf_channels(channels): + raise ValueError(f'Experiment {index} does not have both non-spin-flip channels; spin asymmetry needs pp and mm.') + + experiment = self._experiments[index] + q, r_pp, var_pp = _ordered_channel_arrays(experiment[PolarizationChannel.PP]) + q_mm, r_mm_source, var_mm_source = _ordered_channel_arrays(experiment[PolarizationChannel.MM]) + + out_of_overlap = 0 + if q.shape == q_mm.shape and np.allclose(q, q_mm, rtol=1e-9, atol=0.0): + # The usual case: both channels come from one instrument scan. + # A tolerance keeps grids that only differ by float round-trips + # (text files) on this path. + r_mm, var_mm = r_mm_source, var_mm_source + else: + # Different grids: put mm on the pp grid, but only where mm has data + # — np.interp clamps outside its range, which would silently invent + # measurements at the edges. + inside = (q >= q_mm.min()) & (q <= q_mm.max()) + out_of_overlap = int(np.count_nonzero(~inside)) + if out_of_overlap: + logger.warning( + 'Spin asymmetry of experiment %s: %s of %s pp points lie outside the mm q range ' + '[%.5g, %.5g] and are dropped.', + index, + out_of_overlap, + q.size, + q_mm.min(), + q_mm.max(), + ) + q, r_pp, var_pp = q[inside], r_pp[inside], var_pp[inside] + r_mm, var_mm = _interpolate_with_variance(q, q_mm, r_mm_source, var_mm_source) + + asymmetry, variance, keep, reasons = _spin_asymmetry(r_pp, r_mm, var_pp, var_mm) + measured = DataSet1D( + name=f'Spin asymmetry for Experiment {index}', + x=q[keep], + y=asymmetry[keep], + ye=variance[keep], + x_label='q (1/angstrom)', + y_label='Spin asymmetry', + ) + + calculated = None + model_index = self._model_index_for_experiment(experiment) + if model_index is not None and self.models[model_index].has_magnetism and measured.x.size > 0: + calculated_pp = self.model_data_for_model_at_index(model_index, q_range=measured.x, channel='pp').y + calculated_mm = self.model_data_for_model_at_index(model_index, q_range=measured.x, channel='mm').y + calculated_asymmetry, _, _, _ = _spin_asymmetry(calculated_pp, calculated_mm) + calculated = DataSet1D( + name=f'Calculated spin asymmetry for Experiment {index}', + x=measured.x, + y=calculated_asymmetry, + x_label='q (1/angstrom)', + y_label='Spin asymmetry', + ) + + return { + 'measured': measured, + 'calculated': calculated, + 'masked_points': int(np.count_nonzero(~keep)), + 'out_of_overlap_points': out_of_overlap, + **reasons, + } + + def _model_index_for_experiment(self, experiment) -> Optional[int]: + """Index of the model an experiment is bound to, or None.""" + model = getattr(experiment, 'model', None) + if model is None: + return None + for model_index, candidate in enumerate(self._models): + if candidate is model: + return model_index + return None def default_model(self): """Default model.""" @@ -902,6 +1390,9 @@ def _as_dict_add_experiments(self, project_dict: dict): project_dict['experiments_names'] = {} for key, experiment in self._experiments.items(): + if isinstance(experiment, PolarizedDataSet): + self._as_dict_add_polarized_experiment(project_dict, key, experiment) + continue project_dict['experiments'][key] = [ list(experiment.x), list(experiment.y), @@ -912,6 +1403,31 @@ def _as_dict_add_experiments(self, project_dict: dict): project_dict['experiments_models'][key] = experiment.model.name project_dict['experiments_names'][key] = experiment.name + @staticmethod + def _as_dict_add_polarized_experiment(project_dict: dict, key: int, experiment: PolarizedDataSet) -> None: + """Serialize a `PolarizedDataSet`: one (name, x, y, ye, xe) array set per measured channel. + + `experiments[key]` is a plain list for an ordinary `DataSet1D` (see + `_as_dict_add_experiments`); a dict here — tagged `'polarized': True` — + is how `_from_dict_extract_experiments` tells the two apart on load. + """ + project_dict['experiments'][key] = { + 'polarized': True, + 'channels': { + channel.value: [ + experiment[channel].name, + list(experiment[channel].x), + list(experiment[channel].y), + list(experiment[channel].ye), + list(experiment[channel].xe), + ] + for channel in experiment.available_channels + }, + } + if experiment.model is not None: + project_dict['experiments_models'][key] = experiment.model.name + project_dict['experiments_names'][key] = experiment.name + def from_dict(self, project_dict: dict): """From dict.""" keys = list(project_dict.keys()) @@ -955,21 +1471,45 @@ def from_dict(self, project_dict: dict): # Resolve any pending parameter dependencies (constraints) after all objects are loaded resolve_all_parameter_dependencies(self) - def _from_dict_extract_experiments(self, project_dict: dict) -> Dict[int, DataSet1D]: + def _from_dict_extract_experiments(self, project_dict: dict) -> Dict[int, Union[DataSet1D, PolarizedDataSet]]: """From dict extract experiments.""" experiments = {} - for key in project_dict['experiments'].keys(): + for key, raw in project_dict['experiments'].items(): + if isinstance(raw, dict) and raw.get('polarized'): + experiments[int(key)] = self._polarized_experiment_from_dict(key, raw, project_dict) + continue experiments[int(key)] = DataSet1D( name=project_dict['experiments_names'][key], - x=project_dict['experiments'][key][0], - y=project_dict['experiments'][key][1], - ye=project_dict['experiments'][key][2], - xe=project_dict['experiments'][key][3], + x=raw[0], + y=raw[1], + ye=raw[2], + xe=raw[3], model=self._models[project_dict['experiments_models'][key]], auto_background=False, ) return experiments + def _polarized_experiment_from_dict(self, key: str, raw: dict, project_dict: dict) -> PolarizedDataSet: + """Reconstruct a `PolarizedDataSet` serialized by `_as_dict_add_polarized_experiment`.""" + model = self._models[project_dict['experiments_models'][key]] + channels = { + channel_value: DataSet1D( + name=arrays[0], + x=arrays[1], + y=arrays[2], + ye=arrays[3], + xe=arrays[4], + model=model, + auto_background=False, + ) + for channel_value, arrays in raw['channels'].items() + } + return PolarizedDataSet( + name=project_dict['experiments_names'][key], + channels=channels, + model=model, + ) + def _get_materials_in_models(self) -> MaterialCollection: """Get materials in models.""" materials_in_model = MaterialCollection(populate_if_none=False) @@ -991,3 +1531,190 @@ def _replace_collection(self, src_collection: BaseCollection, dst_collection: Ba def _timestamp_modification(self): """Timestamp modification.""" self._info['modified'] = datetime.datetime.now().strftime('%d.%m.%Y %H:%M') + + +def _unwrapped_angle(angle: np.ndarray, mask: np.ndarray) -> np.ndarray: + """Make an angle profile continuous along z within each magnetic region. + + The moment angle is periodic, so a profile that turns smoothly from 359° to + 1° comes back from `atan2` as a jump of nearly 360°. Drawn as a line that is + a full sweep across the chart where the moment barely moves. Each contiguous + run of `mask` (a magnetic region) is therefore unwrapped on its own and then + shifted by whole turns so it sits as close to the conventional [0, 360) + range as possible — neighbouring regions stay independent, since the angle + between them is not defined. + + Parameters + ---------- + angle : np.ndarray + Wrapped angles in degrees. + mask : np.ndarray + Which points carry a moment. + + Returns + ------- + np.ndarray + Angles in degrees, continuous within each masked region. Points outside + the mask are returned unchanged (callers drop them). + """ + unwrapped = np.array(angle, dtype=float, copy=True) + if mask.size == 0 or not np.any(mask): + return unwrapped + + # Each contiguous run of masked points is one magnetic region. + masked_indices = np.flatnonzero(mask) + region_breaks = np.flatnonzero(np.diff(masked_indices) > 1) + 1 + for region in np.split(masked_indices, region_breaks): + segment = np.unwrap(unwrapped[region], period=360.0) + # Keep the drawn values near the usual range rather than at 720 deg. + turns = np.round(np.median(segment) / 360.0 - 0.5) + unwrapped[region] = segment - turns * 360.0 + return unwrapped + + +def _has_both_nsf_channels(channels: List[PolarizationChannel]) -> bool: + """Whether both non-spin-flip channels (pp, mm) — the pair spin asymmetry needs — are present.""" + return PolarizationChannel.PP in channels and PolarizationChannel.MM in channels + + +def _ordered_channel_arrays(dataset: DataSet1D) -> tuple: + """A channel's (q, reflectivity, variance) arrays, ordered and checked. + + Spin asymmetry pairs two channels point by point and interpolates one onto + the other, both of which assume well-formed, strictly increasing q. Rather + than trusting that, the arrays are checked here and sorted when needed — + `np.interp` silently returns nonsense for a descending grid. + + Raises + ------ + ValueError + The dataset is empty, its arrays disagree in length or shape, its q + values are not finite, or it visits the same q twice. + """ + name = getattr(dataset, 'name', '?') + q = np.asarray(getattr(dataset, 'x', np.empty(0)), dtype=float).ravel() + y = np.asarray(getattr(dataset, 'y', np.empty(0)), dtype=float).ravel() + if q.size == 0: + raise ValueError(f"Channel '{name}' has no data points.") + if q.size != y.size: + raise ValueError(f"Channel '{name}' has {q.size} q values for {y.size} reflectivities.") + if not np.all(np.isfinite(q)): + raise ValueError(f"Channel '{name}' has non-finite q values.") + + variance = np.asarray(getattr(dataset, 'ye', None) if getattr(dataset, 'ye', None) is not None else [], dtype=float) + variance = variance.ravel() + if variance.size == 0: + variance = np.zeros_like(y) + elif variance.size != y.size: + logger.warning( + "Channel '%s' has %s uncertainties for %s points; treating it as having none.", + name, + variance.size, + y.size, + ) + variance = np.zeros_like(y) + + order = np.argsort(q, kind='stable') + if not np.array_equal(order, np.arange(q.size)): + logger.warning("Channel '%s' is not ordered in q; sorting it before pairing.", name) + q, y, variance = q[order], y[order], variance[order] + # A single-point channel has an empty `np.diff`, so the duplicate-q check + # below is vacuously satisfied and it passes through here unrejected. That + # is intentional: a one-point channel is a legitimate (if degenerate) SA + # pair when it lines up exactly with the other channel's grid, and + # `_interpolate_with_variance` handles a size-1 `q_source` correctly (its + # clipped `searchsorted` result always resolves to that single point). + if np.any(np.diff(q) <= 0): + raise ValueError(f"Channel '{name}' visits the same q more than once; the pairing would be ambiguous.") + return q, y, variance + + +def _interpolate_with_variance(q: np.ndarray, q_source: np.ndarray, values: np.ndarray, variances: np.ndarray) -> tuple: + """Linear interpolation of values and their variances onto `q`. + + Values use the linear weights (1−t, t); variances use their **squares**, + which is the propagation rule for independent endpoints — interpolating a + variance linearly (as `np.interp` would) overestimates it, by a factor 2 at + the midpoint of two equal variances. + + `q` must lie inside `q_source`; the caller restricts it to the overlap. + """ + upper = np.clip(np.searchsorted(q_source, q, side='left'), 1, q_source.size - 1) + lower = upper - 1 + span = q_source[upper] - q_source[lower] + # span is > 0 for a strictly increasing source grid; guard anyway. + weight = np.where(span > 0, (q - q_source[lower]) / np.where(span > 0, span, 1.0), 0.0) + interpolated = (1.0 - weight) * values[lower] + weight * values[upper] + interpolated_variance = (1.0 - weight) ** 2 * variances[lower] + weight**2 * variances[upper] + return interpolated, interpolated_variance + + +def _spin_asymmetry( + r_pp: np.ndarray, + r_mm: np.ndarray, + var_pp: Optional[np.ndarray] = None, + var_mm: Optional[np.ndarray] = None, +) -> tuple: + """Spin asymmetry, its variance, which points to keep, and why not. + + Parameters + ---------- + r_pp, r_mm : np.ndarray + Non-spin-flip reflectivities on a common q grid. + var_pp, var_mm : Optional[np.ndarray] + Their variances (`DataSet1D.ye`), or None for a calculated curve. + + Returns + ------- + tuple + SA, its variance (zeros without input variances), a boolean mask of the + points to keep, and a dict counting the dropped ones by reason. + """ + r_pp = np.asarray(r_pp, dtype=float) + r_mm = np.asarray(r_mm, dtype=float) + denominator = r_pp + r_mm + var_pp = np.zeros_like(r_pp) if var_pp is None else np.asarray(var_pp, dtype=float) + var_mm = np.zeros_like(r_mm) if var_mm is None else np.asarray(var_mm, dtype=float) + + # A denominator of exactly zero would divide by zero; those points are + # dropped by the masks below anyway. + safe_denominator = np.where(denominator == 0, np.nan, denominator) + with np.errstate(invalid='ignore', divide='ignore'): + asymmetry = (r_pp - r_mm) / safe_denominator + # sigma_SA = 2 sqrt(R--^2 sigma_++^2 + R++^2 sigma_--^2) / (R++ + R--)^2, + # so the variance is its square. ye holds variances, hence no squaring + # of var_pp / var_mm here. + variance = 4.0 * (r_mm**2 * var_pp + r_pp**2 * var_mm) / safe_denominator**4 + + asymmetry = np.nan_to_num(asymmetry, nan=0.0, posinf=0.0, neginf=0.0) + variance = np.nan_to_num(variance, nan=0.0, posinf=0.0, neginf=0.0) + + # A point with a non-finite reflectivity, or an uncertainty that is not a + # usable variance, cannot produce a meaningful SA — and must not be silently + # demoted to "no uncertainty", which would also skip the significance test. + invalid = ~np.isfinite(r_pp) | ~np.isfinite(r_mm) + invalid |= ~np.isfinite(var_pp) | ~np.isfinite(var_mm) | (var_pp < 0) | (var_mm < 0) + + # Uncertainty-independent guard: the denominator must be positive and must + # not be the small remainder of two much larger numbers. Reflectivities are + # positive, so this only bites on background-subtracted data — which is + # exactly where SA otherwise explodes to +/-1e3 and destroys the axis. + magnitude = np.abs(r_pp) + np.abs(r_mm) + with np.errstate(invalid='ignore'): + degenerate = ~np.isfinite(denominator) | (denominator <= 0) + degenerate |= np.abs(denominator) <= SPIN_ASYMMETRY_CANCELLATION_FRACTION * magnitude + degenerate &= ~invalid + + # Uncertainty-based guard: is the denominator above the noise? + denominator_sigma = np.sqrt(np.clip(var_pp + var_mm, 0.0, None)) + with_uncertainty = denominator_sigma > 0 + insignificant = with_uncertainty & (denominator <= SPIN_ASYMMETRY_SIGNIFICANCE * denominator_sigma) + insignificant &= ~invalid & ~degenerate + + keep = ~invalid & ~degenerate & ~insignificant + reasons = { + 'invalid_points': int(np.count_nonzero(invalid)), + 'small_denominator_points': int(np.count_nonzero(degenerate)), + 'low_significance_points': int(np.count_nonzero(insignificant)), + } + return asymmetry, variance, keep, reasons diff --git a/src/easyreflectometry/sample/__init__.py b/src/easyreflectometry/sample/__init__.py index e6f347ad..e40b90ad 100644 --- a/src/easyreflectometry/sample/__init__.py +++ b/src/easyreflectometry/sample/__init__.py @@ -12,6 +12,7 @@ from .collections.sample import Sample from .elements.layers.layer import Layer from .elements.layers.layer_area_per_molecule import LayerAreaPerMolecule +from .elements.layers.layer_magnetism import LayerMagnetism from .elements.materials.material import Material from .elements.materials.material_density import MaterialDensity from .elements.materials.material_mixture import MaterialMixture @@ -24,6 +25,7 @@ 'Layer', 'LayerAreaPerMolecule', 'LayerCollection', + 'LayerMagnetism', 'Material', 'MaterialCollection', 'MaterialDensity', diff --git a/src/easyreflectometry/sample/elements/layers/layer.py b/src/easyreflectometry/sample/elements/layers/layer.py index 7eea9872..b926d494 100644 --- a/src/easyreflectometry/sample/elements/layers/layer.py +++ b/src/easyreflectometry/sample/elements/layers/layer.py @@ -13,6 +13,7 @@ from ...base_core import BaseCore from ..materials.material import Material +from .layer_magnetism import LayerMagnetism DEFAULTS = { 'thickness': { @@ -45,6 +46,7 @@ def __init__( name: str = 'EasyLayer', unique_name: Optional[str] = None, interface=None, + magnetism: Union[LayerMagnetism, None] = None, ): """Constructor. @@ -58,6 +60,9 @@ def __init__( Layer thickness in Angstrom. By default, None. roughness : Union[Parameter, float, None], optional Upper roughness on the layer in Angstrom. By default, None. + magnetism : Union[LayerMagnetism, None], optional + Magnetic properties of the layer; None for a non-magnetic layer. + By default, None. name : str, optional Name of the layer. By default, 'EasyLayer'. interface : @@ -91,10 +96,30 @@ def __init__( self._material = material self._thickness = thickness self._roughness = roughness + self._magnetism = magnetism if interface is not None: self.interface = interface + # ----- interface (override BaseCore's to switch on calculator magnetism) ----- + + @BaseCore.interface.setter + def interface(self, new_interface) -> None: + """Set the interface; runs `generate_bindings` and, for a magnetic layer, + enables magnetism on the calculator (raising if it does not support it). + """ + BaseCore.interface.fset(self, new_interface) + if new_interface is not None and self._magnetism is not None: + self._enable_calculator_magnetism() + + def _enable_calculator_magnetism(self) -> None: + """Turn on magnetism on the attached calculator. + + Raises `NotImplementedError` when the active calculator cannot model + magnetic samples (e.g. refnx or bornagain). + """ + self.interface().include_magnetism = True + @property def material(self) -> Material: return self._material @@ -119,6 +144,34 @@ def roughness(self) -> Parameter: def roughness(self, value: float) -> None: self._roughness.value = value + @property + def magnetism(self) -> Optional[LayerMagnetism]: + return self._magnetism + + @magnetism.setter + def magnetism(self, value: Optional[LayerMagnetism]) -> None: + """Attach or remove the magnetic properties of this layer. + + Attaching regenerates the calculator bindings so `rho_m`/`theta_m` become + live on the backend; removing drops the layer's magnetic state from the + backend (and switches calculator magnetism off entirely when this was the + last magnetic layer). + """ + if value is None and self._magnetism is not None: + if self.interface is not None: + self.interface().remove_layer_magnetism(self.unique_name) + # Detach the calculator callbacks of the removed parameters so later + # value changes on the detached object no longer reach the backend. + # `property()` (fget/fset/fdel all None) is easyscience's own "no + # callback" sentinel -- `Parameter.__copy__` sets it the same way -- + # so a later `fset` guard in `Parameter` cleanly no-ops. + self._magnetism.rho_m._callback = property() + self._magnetism.theta_m._callback = property() + self._magnetism = value + if value is not None and self.interface is not None: + self._enable_calculator_magnetism() + self.generate_bindings() + def assign_material(self, material: Material) -> None: """Assign a material to the layer interface. @@ -135,10 +188,13 @@ def assign_material(self, material: Material) -> None: @property def _dict_repr(self) -> dict[str, str]: """A simplified dict representation.""" - return { + this_dict = { self.name: { 'material': self.material._dict_repr, 'thickness': f'{self.thickness.value:.3f} {self.thickness.unit}', 'roughness': f'{self.roughness.value:.3f} {self.roughness.unit}', } } + if self._magnetism is not None: + this_dict[self.name]['magnetism'] = self._magnetism._dict_repr + return this_dict diff --git a/src/easyreflectometry/sample/elements/layers/layer_magnetism.py b/src/easyreflectometry/sample/elements/layers/layer_magnetism.py new file mode 100644 index 00000000..dcf16132 --- /dev/null +++ b/src/easyreflectometry/sample/elements/layers/layer_magnetism.py @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + + +from typing import Optional +from typing import Union + +import numpy as np +from easyscience import global_object +from easyscience.variable import Parameter + +from easyreflectometry.utils import get_as_parameter + +from ...base_core import BaseCore + +DEFAULTS = { + 'rho_m': { + 'description': 'The magnetic scattering length density of the layer in e-6 per squared angstrom.', + 'url': 'https://refl1d.readthedocs.io/en/latest/guide/magnetism.html', + 'value': 0.0, + 'unit': '1 / angstrom^2', + 'min': -np.inf, + 'max': np.inf, + 'fixed': True, + }, + 'theta_m': { + 'description': 'The angle of the in-plane magnetic moment with respect to the beam direction in degrees. ' + 'The default of 270 degrees aligns the moment with the default guide field (Aguide), giving no spin-flip.', + 'url': 'https://refl1d.readthedocs.io/en/latest/guide/magnetism.html', + 'value': 270.0, + 'unit': 'degree', + 'min': 0.0, + 'max': 360.0, + 'fixed': True, + }, +} + + +class LayerMagnetism(BaseCore): + """Magnetic properties of a layer: magnetic SLD amplitude and in-plane moment angle. + + Attach to a `Layer` via its `magnetism` argument/property to make the layer magnetic. + Both attributes are `Parameter` objects, so they can be fitted and serialized like any + other sample parameter. A calculator that supports magnetism (refl1d) binds them as + `magnetism_rhoM` / `magnetism_thetaM` on the corresponding slab. + """ + + def __init__( + self, + rho_m: Union[Parameter, float, None] = None, + theta_m: Union[Parameter, float, None] = None, + name: str = 'EasyLayerMagnetism', + unique_name: Optional[str] = None, + interface=None, + ): + """Constructor. + + Parameters + ---------- + rho_m : Union[Parameter, float, None], optional + Magnetic scattering length density in e-6 per squared angstrom. By default, 0. + theta_m : Union[Parameter, float, None], optional + In-plane moment angle in degrees. By default, 270 (aligned with the default + guide field, i.e. no spin-flip). + name : str, optional + Name of the magnetism element. By default, 'EasyLayerMagnetism'. + unique_name : Optional[str], optional + By default, None. + interface : + Calculator interface. By default, None. + """ + if unique_name is None: + unique_name = global_object.generate_unique_name(self.__class__.__name__) + + rho_m_value = rho_m + rho_m = get_as_parameter( + name='rho_m', + value=rho_m, + default_dict=DEFAULTS, + unique_name_prefix=f'{unique_name}_RhoM', + ) + # The default bounds are infinite; `Project._sync_parameter_states` + # narrows them to the shared SLD window unless the caller passed an + # explicit Parameter with its own bounds (same contract as `Layer`). + rho_m.default_limits_pending = not isinstance(rho_m_value, Parameter) + theta_m = get_as_parameter( + name='theta_m', + value=theta_m, + default_dict=DEFAULTS, + unique_name_prefix=f'{unique_name}_ThetaM', + ) + + super().__init__(name=name, unique_name=unique_name) + self._rho_m = rho_m + self._theta_m = theta_m + + if interface is not None: + self.interface = interface + + @property + def rho_m(self) -> Parameter: + return self._rho_m + + @rho_m.setter + def rho_m(self, value: float) -> None: + self._rho_m.value = value + + @property + def theta_m(self) -> Parameter: + return self._theta_m + + @theta_m.setter + def theta_m(self, value: float) -> None: + self._theta_m.value = value + + # Representation + @property + def _dict_repr(self) -> dict[str, str]: + """A simplified dict representation.""" + return { + self.name: { + 'rho_m': f'{self._rho_m.value:.3f}e-6 {self._rho_m.unit}', + 'theta_m': f'{self._theta_m.value:.3f} {self._theta_m.unit}', + } + } diff --git a/src/easyreflectometry/summary/summary.py b/src/easyreflectometry/summary/summary.py index f40f23ad..89b82f48 100644 --- a/src/easyreflectometry/summary/summary.py +++ b/src/easyreflectometry/summary/summary.py @@ -48,6 +48,9 @@ def _silence_pdf_converter(): _NAME_MAX_LEN = 20 +# Fixed per-channel colors for polarized experiments, shared with the app so a +# channel keeps the same color in the GUI and in the report. +_CHANNEL_COLORS = {'pp': '#0173B2', 'pm': '#029E73', 'mp': '#CC78BC', 'mm': '#DE8F05'} # Custom href scheme used to pass the full name to QML via TextEdit.hoveredLink. _TOOLTIP_SCHEME = 'nametooltip' @@ -184,22 +187,63 @@ def save_sld_plot(self, filename: str) -> None: fig.savefig(filename, dpi=600) plt.close() + def _measured_series(self) -> list: + """Measured data of experiment 0 as ``(label, dataset, color, channel)`` entries. + + One entry per measured spin channel for a polarized experiment (each + with its own channel color and channel key), a single channel-less + entry for an ordinary experiment, and an empty list when no experiment + is loaded. + """ + try: + experiment = self._project.experimental_data_for_model_at_index(0) + except IndexError: + return [] + + channels = self._project.experiment_channels_at_index(0) + if not channels: + return [('Experiment', experiment, 'red', None)] + return [ + (f'Experiment ({channel.value})', experiment[channel], _CHANNEL_COLORS[channel.value], channel) + for channel in channels + ] + + def _model_curve(self, channel=None): + """Calculated curve for experiment 0, per spin channel when asked. + + Returns None when the requested channel cannot be calculated (e.g. a + spin-flip channel on a non-magnetic model) — an incorrect overlay is + worse than none. + """ + try: + return self._project.model_data_for_model_at_index(0, channel=channel) + except (ValueError, NotImplementedError) as exception: + logging.getLogger(__name__).warning( + 'No calculated curve for channel %s: %s', getattr(channel, 'value', channel), exception + ) + return None + def save_fit_experiment_plot(self, filename: str) -> None: """Save fit experiment plot.""" fig = plt.figure() ax = fig.add_subplot(1, 1, 1) legends = [] - model = self._project.model_data_for_model_at_index(0) - ax.plot(model.x, np.log10(model.y), color='blue') - legends.append('Model') - - try: - experiment = self._project.experimental_data_for_model_at_index(0) - ax.plot(experiment.x, np.log10(experiment.y), color='red') - legends.append('Experiment') - except IndexError: - pass + measured_series = self._measured_series() + # One calculated curve per measured channel; the ordinary single curve + # when the experiment is unpolarized or nothing is loaded. + model_channels = [entry[3] for entry in measured_series] or [None] + for channel in model_channels: + model = self._model_curve(channel) + if model is None: + continue + color = 'blue' if channel is None else _CHANNEL_COLORS[channel.value] + ax.plot(model.x, np.log10(model.y), color=color) + legends.append('Model' if channel is None else f'Model ({channel.value})') + + for label, dataset, color, _channel in measured_series: + ax.plot(dataset.x, np.log10(dataset.y), color=color) + legends.append(label) ax.set_xlabel('Q (Å⁻¹)') ax.set_ylabel('Reflectivity') @@ -260,28 +304,39 @@ def _experiments_section(self) -> str: html_experiments = [] for idx, experiment in self._project.experiments.items(): - experiment_name = experiment.name - num_data_points = len(experiment.x) - resolution_function = experiment.model.resolution_function.as_dict()['smearing'] - if resolution_function == 'PercentageFwhm': - precentage = experiment.model.resolution_function.as_dict()['constant'] - resolution_function = f'{resolution_function} {precentage}%' - range_min = min(experiment.y) - range_max = max(experiment.y) - range_units = 'Å⁻¹' - html_experiment = HTML_DATA_COLLECTION_TEMPLATE - html_experiment = html_experiment.replace('experiment_name', _truncate_name(experiment_name)) - html_experiment = html_experiment.replace('range_min', _format_value(range_min, 2)) - html_experiment = html_experiment.replace('range_max', _format_value(range_max, 2)) - html_experiment = html_experiment.replace('range_units', f'{range_units}') - html_experiment = html_experiment.replace('num_data_points', f'{num_data_points}') - html_experiment = html_experiment.replace('resolution_function', f'{resolution_function}') - html_experiments.append(html_experiment) + # A polarized experiment holds one dataset per spin channel; report + # one row per channel, as the plots already draw one curve each. + channels = getattr(experiment, 'available_channels', None) + if channels is None: + rows = [(experiment.name, experiment)] + else: + rows = [(f'{experiment.name} ({channel.value})', experiment[channel]) for channel in channels] + for row_name, dataset in rows: + html_experiments.append(self._experiment_row(row_name, dataset, experiment.model)) html_experiments_str = '\n'.join(html_experiments) return html_experiments_str + def _experiment_row(self, experiment_name, dataset, model) -> str: + """One row of the experiments table for a single measured dataset.""" + num_data_points = len(dataset.x) + resolution_function = model.resolution_function.as_dict()['smearing'] + if resolution_function == 'PercentageFwhm': + precentage = model.resolution_function.as_dict()['constant'] + resolution_function = f'{resolution_function} {precentage}%' + range_min = min(dataset.y) + range_max = max(dataset.y) + range_units = 'Å⁻¹' + html_experiment = HTML_DATA_COLLECTION_TEMPLATE + html_experiment = html_experiment.replace('experiment_name', _truncate_name(experiment_name)) + html_experiment = html_experiment.replace('range_min', _format_value(range_min, 2)) + html_experiment = html_experiment.replace('range_max', _format_value(range_max, 2)) + html_experiment = html_experiment.replace('range_units', f'{range_units}') + html_experiment = html_experiment.replace('num_data_points', f'{num_data_points}') + html_experiment = html_experiment.replace('resolution_function', f'{resolution_function}') + return html_experiment + def _refinement_section(self) -> str: """Refinement section.""" html_refinement = HTML_REFINEMENT_TEMPLATE @@ -411,30 +466,35 @@ def _fit_experiment_plotly_figure(self): fig = go.Figure() - model = self._project.model_data_for_model_at_index(0) - fig.add_trace( - go.Scatter( - x=np.asarray(model.x), - y=np.asarray(model.y), - mode='lines', - name='Model', - line={'color': 'blue'}, + measured_series = self._measured_series() + # One calculated curve per measured channel; the ordinary single curve + # when the experiment is unpolarized or nothing is loaded. + model_channels = [entry[3] for entry in measured_series] or [None] + for channel in model_channels: + model = self._model_curve(channel) + if model is None: + continue + color = 'blue' if channel is None else _CHANNEL_COLORS[channel.value] + fig.add_trace( + go.Scatter( + x=np.asarray(model.x), + y=np.asarray(model.y), + mode='lines', + name='Model' if channel is None else f'Model ({channel.value})', + line={'color': color}, + ) ) - ) - try: - experiment = self._project.experimental_data_for_model_at_index(0) + for label, dataset, color, _channel in measured_series: fig.add_trace( go.Scatter( - x=np.asarray(experiment.x), - y=np.asarray(experiment.y), + x=np.asarray(dataset.x), + y=np.asarray(dataset.y), mode='markers', - name='Experiment', - marker={'color': 'red', 'size': 4}, + name=label, + marker={'color': color, 'size': 4}, ) ) - except IndexError: - pass fig.update_layout( xaxis_title='Q (Å⁻¹)', diff --git a/tests/calculators/refl1d/test_refl1d_calculator.py b/tests/calculators/refl1d/test_refl1d_calculator.py index 50de2db4..2bdfa410 100644 --- a/tests/calculators/refl1d/test_refl1d_calculator.py +++ b/tests/calculators/refl1d/test_refl1d_calculator.py @@ -155,6 +155,43 @@ def test_calculate_magnetic(self): ] assert_almost_equal(actual, expected, decimal=4) + def test_polarized_reflectivity_profiles(self): + p = Refl1d() + p.include_magnetism = True + p._wrapper.create_material('Material1') + p._wrapper.update_material('Material1', rho=0.000, irho=0.000) + p._wrapper.create_material('Material2') + p._wrapper.update_material('Material2', rho=4.000, irho=0.000) + p._wrapper.create_material('Material3') + p._wrapper.update_material('Material3', rho=2.047, irho=0.000) + p._wrapper.create_model('MyModel') + p._wrapper.create_layer('Layer1') + p._wrapper.assign_material_to_layer('Material1', 'Layer1') + p._wrapper.create_layer('Layer2') + p._wrapper.assign_material_to_layer('Material2', 'Layer2') + p._wrapper.update_layer('Layer2', thickness=100, interface=0) + p._wrapper.update_layer('Layer2', magnetism_rhoM=2, magnetism_thetaM=45) + p._wrapper.create_layer('Layer3') + p._wrapper.assign_material_to_layer('Material3', 'Layer3') + p._wrapper.create_item('Item') + p._wrapper.add_layer_to_item('Layer1', 'Item') + p._wrapper.add_layer_to_item('Layer2', 'Item') + p._wrapper.add_layer_to_item('Layer3', 'Item') + p._wrapper.add_item('Item', 'MyModel') + q = np.linspace(0.005, 0.3, 50) + + channels = p.polarized_reflectivity_profiles(q, 'MyModel') + + assert_equal(list(channels.keys()), ['pp', 'pm', 'mp', 'mm']) + for reflectivity in channels.values(): + assert_equal(len(reflectivity), len(q)) + + # reflectity_profile follows the selected channel + for key in ['pp', 'pm', 'mp', 'mm']: + p.polarization_channel = key + assert_equal(p.polarization_channel.value, key) + assert_almost_equal(p.reflectity_profile(q, 'MyModel'), channels[key]) + def test_sld_profile(self): p = Refl1d() p._wrapper.create_material('Material1') diff --git a/tests/calculators/refl1d/test_refl1d_wrapper.py b/tests/calculators/refl1d/test_refl1d_wrapper.py index 57c43ee8..49ef2966 100644 --- a/tests/calculators/refl1d/test_refl1d_wrapper.py +++ b/tests/calculators/refl1d/test_refl1d_wrapper.py @@ -10,9 +10,12 @@ from unittest.mock import patch import numpy as np +import pytest +from numpy.testing import assert_allclose from numpy.testing import assert_almost_equal from numpy.testing import assert_equal +from easyreflectometry.calculators.polarization import PolarizationChannel from easyreflectometry.calculators.refl1d.wrapper import Refl1dWrapper from easyreflectometry.calculators.refl1d.wrapper import _build_sample from easyreflectometry.calculators.refl1d.wrapper import _get_oversampling_q @@ -377,9 +380,11 @@ def test_get_polarized_probe(): assert all(probe.dQ == dq) assert len(probe.calc_Q) == len(q) assert len(probe.xs) == 4 - assert probe.xs[1:4] == [None, None, None] - assert probe.xs[0].intensity.value == 10 - assert probe.xs[0].background.value == 20 + for cross_section in probe.xs: + assert cross_section is not None + assert cross_section.intensity.value == 10 + assert cross_section.background.value == 20 + assert len(cross_section.calc_Q) == len(q) def test_get_polarized_probe_oversampling(): @@ -396,33 +401,253 @@ def test_get_polarized_probe_oversampling(): probe = _get_polarized_probe(q_array=q, dq_array=dq, model_name=model_name, storage=storage, oversampling_factor=2) # Then - assert len(probe.xs[0].calc_Qo) == 2 * len(q) + for cross_section in probe.xs: + assert len(cross_section.calc_Qo) == 2 * len(q) -def test_get_polarized_probe_polarization(): - # When - q = np.linspace(1, 10, 10) - dq = np.linspace(0.01, 0.1, 10) - model_name = 'model_name' +Q_POLARIZED = np.linspace(0.005, 0.3, 100) - storage = {'model': {model_name: {}}} - storage['model'][model_name]['scale'] = 10.0 - storage['model'][model_name]['bkg'] = 20.0 - # Then - probe = _get_polarized_probe( - q_array=q, - dq_array=dq, - model_name=model_name, - storage=storage, - all_polarizations=True, - ) +def _sample_wrapper(rho: float, magnetic: bool, rhoM: float = 0.0, thetaM: float = 270.0) -> Refl1dWrapper: + """Vacuum | 100 A layer of `rho` (optionally magnetic) | Si substrate. - # Expect - assert len(probe.xs[0].calc_Q) == len(q) - assert len(probe.xs[1].calc_Q) == len(q) - assert len(probe.xs[2].calc_Q) == len(q) - assert len(probe.xs[3].calc_Q) == len(q) + Magnetic values may be set via `update_layer` at any time (also one key at a + time); they are stored per layer and attached to the slabs whenever magnetism + is enabled. + """ + p = Refl1dWrapper() + if magnetic: + p.magnetism = True + p.create_material('Vacuum') + p.update_material('Vacuum', rho=0.0, irho=0.0) + p.create_material('MaterialMag') + p.update_material('MaterialMag', rho=rho, irho=0.0) + p.create_material('Si') + p.update_material('Si', rho=2.047, irho=0.0) + p.create_model('MyModel') + p.create_layer('Superphase') + p.assign_material_to_layer('Vacuum', 'Superphase') + p.create_layer('LayerMag') + p.assign_material_to_layer('MaterialMag', 'LayerMag') + p.update_layer('LayerMag', thickness=100, interface=0) + if magnetic: + p.update_layer('LayerMag', magnetism_rhoM=rhoM, magnetism_thetaM=thetaM) + p.create_layer('Subphase') + p.assign_material_to_layer('Si', 'Subphase') + p.create_item('Item') + p.add_layer_to_item('Superphase', 'Item') + p.add_layer_to_item('LayerMag', 'Item') + p.add_layer_to_item('Subphase', 'Item') + p.add_item('Item', 'MyModel') + return p + + +def test_calculate_polarized_shape(): + p = _sample_wrapper(rho=4.0, magnetic=True, rhoM=2.0, thetaM=45) + + channels = p.calculate_polarized(Q_POLARIZED, 'MyModel') + + assert list(channels.keys()) == ['pp', 'pm', 'mp', 'mm'] + for reflectivity in channels.values(): + assert isinstance(reflectivity, np.ndarray) + assert len(reflectivity) == len(Q_POLARIZED) + assert np.all(np.isfinite(reflectivity)) + + +def test_calculate_follows_selected_channel(): + p = _sample_wrapper(rho=4.0, magnetic=True, rhoM=2.0, thetaM=45) + channels = p.calculate_polarized(Q_POLARIZED, 'MyModel') + + for channel in PolarizationChannel: + p.polarization_channel = channel + assert_allclose(p.calculate(Q_POLARIZED, 'MyModel'), channels[channel.value], rtol=1e-10) + + +def test_calculate_polarized_zero_magnetic_sld(): + # Polarized calculation with zero magnetic SLD (magnetism enabled, rhoM=0): + # the non-spin-flip channels degenerate to the unpolarized result and the + # spin-flip channels vanish. + p = _sample_wrapper(rho=4.0, magnetic=True, rhoM=0.0, thetaM=270) + unpolarized = _sample_wrapper(rho=4.0, magnetic=False) + + channels = p.calculate_polarized(Q_POLARIZED, 'MyModel') + reference = unpolarized.calculate(Q_POLARIZED, 'MyModel') + + assert_allclose(channels['pp'], reference, rtol=1e-5) + assert_allclose(channels['mm'], reference, rtol=1e-5) + # Tolerance pinned from the observed numerics of refl1d 1.0.0 (machine noise). + assert np.max(channels['pm']) < 1e-16 + assert np.max(channels['mp']) < 1e-16 + + +def test_calculate_polarized_channel_ordering(): + # Pins the pp/mm halves of POLARIZATION_CHANNEL_TO_INDEX with physics, guarding + # against a pp/mm swap: with the moment collinear with the neutron polarization + # axis there is no spin flip and the non-spin-flip channels see rho +/- rhoM. + # refl1d returns cross-sections in probe._xs_names order ['mm','mp','pm','pp'] + # (refl1d/probe/probe.py; magnetic_amplitude returns (--,-+,+-,++)). With the + # default guide field (Aguide=270), thetaM=270 is the moment-parallel-to-field + # orientation: spin-up ('pp') sees rho + rhoM, spin-down ('mm') sees rho - rhoM. + # thetaM=90 (anti-parallel) swaps the two; both are spin-flip-free. + rho, rhoM = 4.0, 2.0 + plus = _sample_wrapper(rho=rho + rhoM, magnetic=False) + minus = _sample_wrapper(rho=rho - rhoM, magnetic=False) + reflectivity_plus = plus.calculate(Q_POLARIZED, 'MyModel') + reflectivity_minus = minus.calculate(Q_POLARIZED, 'MyModel') + + aligned = _sample_wrapper(rho=rho, magnetic=True, rhoM=rhoM, thetaM=270) + channels = aligned.calculate_polarized(Q_POLARIZED, 'MyModel') + assert_allclose(channels['pp'], reflectivity_plus, rtol=1e-4, atol=1e-9) + assert_allclose(channels['mm'], reflectivity_minus, rtol=1e-4, atol=1e-9) + assert np.max(channels['pm']) < 1e-16 + assert np.max(channels['mp']) < 1e-16 + + anti_aligned = _sample_wrapper(rho=rho, magnetic=True, rhoM=rhoM, thetaM=90) + channels = anti_aligned.calculate_polarized(Q_POLARIZED, 'MyModel') + assert_allclose(channels['pp'], reflectivity_minus, rtol=1e-4, atol=1e-9) + assert_allclose(channels['mm'], reflectivity_plus, rtol=1e-4, atol=1e-9) + + +def test_channel_index_map_matches_refl1d_xs_names(): + # The index map must agree with refl1d's own cross-section labels: the wrapper + # extracts Experiment.reflectivity() results in probe.xs order, which is + # PolarizedNeutronProbe._xs_names. This pins the convention at the source so a + # refl1d-side reordering (or a wrapper-side swap) fails loudly. + from refl1d import names as refl1d_names + + from easyreflectometry.calculators.polarization import POLARIZATION_CHANNEL_TO_INDEX + + xs_names = refl1d_names.PolarizedNeutronQProbe._xs_names + assert len(xs_names) == 4 + for channel, index in POLARIZATION_CHANNEL_TO_INDEX.items(): + assert xs_names[index] == channel.value + + +def test_calculate_polarized_spin_flip(): + # Moment perpendicular to the neutron polarization (thetaM=0) produces spin flip; + # a collinear moment (thetaM=90, see test_calculate_polarized_channel_ordering) + # produces essentially none. + aligned = _sample_wrapper(rho=4.0, magnetic=True, rhoM=2.0, thetaM=90) + perpendicular = _sample_wrapper(rho=4.0, magnetic=True, rhoM=2.0, thetaM=0) + + channels_aligned = aligned.calculate_polarized(Q_POLARIZED, 'MyModel') + channels_perpendicular = perpendicular.calculate_polarized(Q_POLARIZED, 'MyModel') + + assert np.max(channels_perpendicular['pm']) > 1e3 * np.max(channels_aligned['pm']) + assert np.max(channels_perpendicular['pm']) > 1e-6 # absolute sanity floor + # pm and mp are identical by symmetry for a non-chiral, non-absorptive sample, + # so this cannot distinguish them: the pm=1 / mp=2 indices rest on the refl1d + # docstring alone ("a sequence pp, pm, mp and mm"). + assert_allclose(channels_perpendicular['pm'], channels_perpendicular['mp'], rtol=1e-10) + + +def test_calculate_polarized_scale_and_background(): + # Intensity and background must reach every cross-section: + # R_out = scale * R + bkg, channel by channel. + scale, bkg = 2.0, 1e-6 + plain = _sample_wrapper(rho=4.0, magnetic=True, rhoM=2.0, thetaM=45) + scaled = _sample_wrapper(rho=4.0, magnetic=True, rhoM=2.0, thetaM=45) + scaled.update_model('MyModel', scale=scale, bkg=bkg) + + channels_plain = plain.calculate_polarized(Q_POLARIZED, 'MyModel') + channels_scaled = scaled.calculate_polarized(Q_POLARIZED, 'MyModel') + + for key in ['pp', 'pm', 'mp', 'mm']: + assert_allclose(channels_scaled[key], scale * channels_plain[key] + bkg, rtol=1e-8) + + +def test_calculate_polarized_requires_magnetism(): + p = _sample_wrapper(rho=4.0, magnetic=False) + with pytest.raises(ValueError): + p.calculate_polarized(Q_POLARIZED, 'MyModel') + + +def test_polarization_channel_normalization(): + p = Refl1dWrapper() + p.magnetism = True + + p.polarization_channel = PolarizationChannel.MM + assert p.polarization_channel is PolarizationChannel.MM + p.polarization_channel = 'pm' + assert p.polarization_channel is PolarizationChannel.PM + + for bad in ['MM', 'xx', None]: + with pytest.raises(ValueError): + p.polarization_channel = bad + + +def test_polarization_channel_requires_magnetism(): + p = Refl1dWrapper() + with pytest.raises(ValueError): + p.polarization_channel = 'mm' + # pp is always allowed + p.polarization_channel = 'pp' + assert p.polarization_channel is PolarizationChannel.PP + + +def test_disabling_magnetism_resets_channel(): + p = _sample_wrapper(rho=4.0, magnetic=True, rhoM=2.0, thetaM=45) + unpolarized = _sample_wrapper(rho=4.0, magnetic=False) + p.polarization_channel = 'mm' + + p.magnetism = False + + # The transition is complete: channel back to pp, slab Magnetism objects + # stripped, and the plain (unpolarized) calculation path works. + assert p.polarization_channel is PolarizationChannel.PP + assert all(layer.magnetism is None for layer in p.storage['layer'].values()) + assert_allclose(p.calculate(Q_POLARIZED, 'MyModel'), unpolarized.calculate(Q_POLARIZED, 'MyModel'), rtol=1e-10) + + # Re-enabling restores the stored magnetic values (rhoM/thetaM survive the + # toggle so the wrapper stays in sync with model parameters that still hold them). + p.magnetism = True + assert p.polarization_channel is PolarizationChannel.PP + restored = _sample_wrapper(rho=4.0, magnetic=True, rhoM=2.0, thetaM=45) + channels = p.calculate_polarized(Q_POLARIZED, 'MyModel') + reference = restored.calculate_polarized(Q_POLARIZED, 'MyModel') + for channel in ('pp', 'pm', 'mp', 'mm'): + assert_allclose(channels[channel], reference[channel], rtol=1e-10) + + +def test_polarized_reflectivities_guards_malformed_output(): + p = _sample_wrapper(rho=4.0, magnetic=True, rhoM=2.0, thetaM=45) + q = Q_POLARIZED + + # Fewer than four cross-sections + with patch('easyreflectometry.calculators.refl1d.wrapper.names.Experiment') as mock_experiment: + mock_experiment.return_value.reflectivity.return_value = [(q, np.ones(len(q)))] * 3 + with pytest.raises(RuntimeError, match='expected 4'): + p.calculate_polarized(q, 'MyModel') + + # Wrong-length cross-section: index 1 is 'mp' in refl1d's (mm, mp, pm, pp) order. + with patch('easyreflectometry.calculators.refl1d.wrapper.names.Experiment') as mock_experiment: + mock_experiment.return_value.reflectivity.return_value = [ + (q, np.ones(len(q))), + (q, np.ones(len(q) - 1)), + (q, np.ones(len(q))), + (q, np.ones(len(q))), + ] + with pytest.raises(RuntimeError, match='malformed mp'): + p.calculate_polarized(q, 'MyModel') + + # Non-finite values: index 3 is 'pp' in refl1d's (mm, mp, pm, pp) order. + with patch('easyreflectometry.calculators.refl1d.wrapper.names.Experiment') as mock_experiment: + bad = np.ones(len(q)) + bad[0] = np.nan + mock_experiment.return_value.reflectivity.return_value = [(q, np.ones(len(q)))] * 3 + [(q, bad)] + with pytest.raises(RuntimeError, match='malformed pp'): + p.calculate_polarized(q, 'MyModel') + + +def test_polarization_channel_survives_reset_storage(): + # reset_storage leaves _magnetism and the resolution function alone; + # the selected channel behaves consistently. + p = Refl1dWrapper() + p.magnetism = True + p.polarization_channel = 'mm' + p.reset_storage() + assert p.polarization_channel is PolarizationChannel.MM + assert p._magnetism is True @patch('easyreflectometry.calculators.refl1d.wrapper.names.Stack') diff --git a/tests/calculators/refnx/test_refnx_wrapper.py b/tests/calculators/refnx/test_refnx_wrapper.py index bb99d633..4ea18d0a 100644 --- a/tests/calculators/refnx/test_refnx_wrapper.py +++ b/tests/calculators/refnx/test_refnx_wrapper.py @@ -28,7 +28,18 @@ def test_init(self): def test_set_magnetism(self): p = RefnxWrapper() with pytest.raises(NotImplementedError): - p.include_magnetism = True + p.magnetism = True + assert p._magnetism is False + + def test_calculate_polarized_not_supported(self): + p = RefnxWrapper() + with pytest.raises(NotImplementedError): + p.calculate_polarized(np.linspace(0.01, 0.3, 10), 'MyModel') + + def test_polarization_channel_requires_magnetism(self): + p = RefnxWrapper() + with pytest.raises(ValueError): + p.polarization_channel = 'mm' def test_reset_storage(self): p = RefnxWrapper() diff --git a/tests/calculators/test_polarization_interface.py b/tests/calculators/test_polarization_interface.py new file mode 100644 index 00000000..55b72f8c --- /dev/null +++ b/tests/calculators/test_polarization_interface.py @@ -0,0 +1,123 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +Model/factory-level tests for polarization channel selection. +""" + +import numpy as np +import pytest +from numpy.testing import assert_allclose + +from easyreflectometry.calculators import CalculatorFactory +from easyreflectometry.calculators import PolarizationChannel +from easyreflectometry.model import Model +from easyreflectometry.model import PercentageFwhm +from easyreflectometry.sample import Layer +from easyreflectometry.sample import Material +from easyreflectometry.sample import Multilayer +from easyreflectometry.sample import Sample + + +def _magnetic_model() -> Model: + vacuum = Material(sld=0, isld=0, name='Vacuum') + material = Material(sld=4.0, isld=0, name='Sld 4') + si = Material(sld=2.047, isld=0, name='Si') + superphase = Layer(material=vacuum, thickness=0, roughness=0, name='Vacuum Superphase') + layer = Layer(material=material, thickness=100, roughness=0, name='Sld 4 Layer') + subphase = Layer(material=si, thickness=0, roughness=0, name='Si Subphase') + sample = Sample(Multilayer(superphase), Multilayer(layer), Multilayer(subphase), name='Sample') + model = Model(sample=sample, scale=1, background=0, name='Magnetic Model') + model.resolution_function = PercentageFwhm(0) + return model + + +Q = np.linspace(0.005, 0.3, 50) + + +def test_polarized_reflectivity_profiles_through_factory(): + model = _magnetic_model() + interface = CalculatorFactory() + interface.switch('refl1d') + model.interface = interface + calculator = model.interface() + calculator.include_magnetism = True + layer_name = list(calculator._wrapper.storage['layer'].keys())[1] + calculator._wrapper.update_layer(layer_name, magnetism_rhoM=2, magnetism_thetaM=45) + + # Through the factory (model.interface), not only the calculator (model.interface()) + channels = model.interface.polarized_reflectivity_profiles(Q, model.unique_name) + + assert list(channels.keys()) == ['pp', 'pm', 'mp', 'mm'] + for reflectivity in channels.values(): + assert len(reflectivity) == len(Q) + + # reflectity_profile (the fitting path) follows the selected channel + calculator.polarization_channel = 'mm' + assert_allclose(model.interface().reflectity_profile(Q, model.unique_name), channels['mm'], rtol=1e-10) + + +def test_switch_resets_polarization_state(): + model = _magnetic_model() + interface = CalculatorFactory() + interface.switch('refl1d') + model.interface = interface + calculator = model.interface() + calculator.include_magnetism = True + calculator.polarization_channel = 'mm' + + # switch() constructs a fresh calculator instance: channel and magnetism reset + interface.switch('refl1d') + + calculator = model.interface() + assert calculator.polarization_channel is PolarizationChannel.PP + assert calculator.include_magnetism is False + + +def test_magnetic_sld_profile_through_factory(): + model = _magnetic_model() + interface = CalculatorFactory() + interface.switch('refl1d') + model.interface = interface + calculator = model.interface() + calculator.include_magnetism = True + layer_name = list(calculator._wrapper.storage['layer'].keys())[1] + calculator._wrapper.update_layer(layer_name, magnetism_rhoM=2, magnetism_thetaM=45) + + z, sld, sld_magnetic, theta_magnetic = interface.magnetic_sld_profile(model.unique_name) + + assert len(z) == len(sld) == len(sld_magnetic) == len(theta_magnetic) + # inside the 100 angstrom magnetic layer (zero roughness, so plateaus are exact) + inside = (z > 25) & (z < 75) + assert_allclose(sld[inside], 4.0) + assert_allclose(sld_magnetic[inside], 2.0) + assert_allclose(theta_magnetic[inside], 45.0) + + +def test_magnetic_sld_profile_requires_magnetism(): + model = _magnetic_model() + interface = CalculatorFactory() + interface.switch('refl1d') + model.interface = interface + + with pytest.raises(ValueError): + interface.magnetic_sld_profile(model.unique_name) + + +def test_refnx_magnetic_sld_profile_raises(): + model = _magnetic_model() + interface = CalculatorFactory() + interface.switch('refnx') + model.interface = interface + + with pytest.raises(NotImplementedError): + interface.magnetic_sld_profile(model.unique_name) + + +def test_refnx_include_magnetism_raises(): + interface = CalculatorFactory() + interface.switch('refnx') + + with pytest.raises(NotImplementedError): + interface().include_magnetism = True + assert interface().include_magnetism is False diff --git a/tests/data/test_polarized.py b/tests/data/test_polarized.py new file mode 100644 index 00000000..7838c2c3 --- /dev/null +++ b/tests/data/test_polarized.py @@ -0,0 +1,191 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +Tests for PolarizedDataSet and spin-channel detection. +""" + +from types import SimpleNamespace +from unittest.mock import patch + +import numpy as np +import pytest + +from easyreflectometry.calculators import PolarizationChannel +from easyreflectometry.data import DataSet1D +from easyreflectometry.data import PolarizedDataSet +from easyreflectometry.data import detect_polarization_channel +from easyreflectometry.data.polarized import _channel_from_filename + + +def _dataset(name: str = 'series') -> DataSet1D: + return DataSet1D(name=name, x=np.array([0.01, 0.02]), y=np.array([1.0, 0.5])) + + +class TestPolarizedDataSet: + def test_requires_at_least_one_channel(self): + with pytest.raises(ValueError): + PolarizedDataSet(channels={}) + + def test_rejects_invalid_channel_key(self): + with pytest.raises(ValueError): + PolarizedDataSet(channels={'xx': _dataset()}) + + def test_rejects_non_dataset_values(self): + with pytest.raises(ValueError): + PolarizedDataSet(channels={'pp': np.array([1.0])}) + + def test_channels_in_canonical_order(self): + p = PolarizedDataSet(channels={'mm': _dataset('a'), 'pm': _dataset('b'), 'pp': _dataset('c')}) + assert p.available_channels == [ + PolarizationChannel.PP, + PolarizationChannel.PM, + PolarizationChannel.MM, + ] + + def test_getitem_and_contains_accept_strings_and_enums(self): + pp = _dataset('up-up') + p = PolarizedDataSet(channels={'pp': pp, 'mm': _dataset('down-down')}) + assert p['pp'] is pp + assert p[PolarizationChannel.PP] is pp + assert 'pp' in p + assert PolarizationChannel.MM in p + assert 'pm' not in p + assert 'xx' not in p + assert len(p) == 2 + + def test_model_propagates_to_channel_datasets(self): + pp = _dataset() + mm = _dataset() + p = PolarizedDataSet(channels={'pp': pp, 'mm': mm}) + assert p.is_simulation + marker = object() + p.model = marker + assert p.is_experiment + assert pp.model is marker + assert mm.model is marker + + def test_channels_view_is_read_only(self): + p = PolarizedDataSet(channels={'pp': _dataset()}) + with pytest.raises(TypeError): + p.channels[PolarizationChannel.MM] = _dataset() + with pytest.raises(TypeError): + del p.channels[PolarizationChannel.PP] + + def test_set_channel_validates_propagates_and_reorders(self): + p = PolarizedDataSet(channels={'mm': _dataset()}) + marker = object() + p.model = marker + + with pytest.raises(ValueError): + p.set_channel('xx', _dataset()) + with pytest.raises(ValueError): + p.set_channel('pp', np.array([1.0])) + + pp = _dataset('up-up') + p.set_channel('pp', pp) + # Canonical order restored, model propagated to the new dataset. + assert p.available_channels == [PolarizationChannel.PP, PolarizationChannel.MM] + assert p['pp'] is pp + assert pp.model is marker + + replacement = _dataset('up-up-2') + p.set_channel(PolarizationChannel.PP, replacement) + assert p['pp'] is replacement + + def test_remove_channel_guards(self): + p = PolarizedDataSet(channels={'pp': _dataset(), 'mm': _dataset()}) + with pytest.raises(ValueError): + p.remove_channel('pm') # not present + p.remove_channel('pp') + assert p.available_channels == [PolarizationChannel.MM] + with pytest.raises(ValueError): + p.remove_channel('mm') # last channel cannot be removed + assert p.available_channels == [PolarizationChannel.MM] + + +class TestChannelDetection: + @pytest.mark.parametrize( + 'filename,expected', + [ + ('sample_uu.dat', PolarizationChannel.PP), + ('sample_pp.ort', PolarizationChannel.PP), + ('sample-up-up.txt', PolarizationChannel.PP), + ('sample_up.dat', PolarizationChannel.PP), + ('run12_plus.txt', PolarizationChannel.PP), + ('sample_dd.dat', PolarizationChannel.MM), + ('sample_down.dat', PolarizationChannel.MM), + ('sample-down-down.txt', PolarizationChannel.MM), + ('sample_ud.dat', PolarizationChannel.PM), + ('sample_up_down.dat', PolarizationChannel.PM), + ('sample_pm.ort', PolarizationChannel.PM), + ('sample_du.dat', PolarizationChannel.MP), + ('sample_down_up.dat', PolarizationChannel.MP), + ('sample_mp.ort', PolarizationChannel.MP), + ('nothing_here.dat', None), + ('d2o_layer.dat', None), + ], + ) + def test_filename_heuristics(self, filename, expected): + assert _channel_from_filename(filename) == expected + + @pytest.mark.parametrize( + 'polarization,expected', + [ + ('pp', PolarizationChannel.PP), + ('mm', PolarizationChannel.MM), + ('pm', PolarizationChannel.PM), + ('mp', PolarizationChannel.MP), + # Partially-analysed observables are not spin channels: 'po' measures + # pp + pm (incident plus, no outgoing analysis), 'mo' measures mp + mm. + ('po', None), + ('mo', None), + ('op', None), + ('om', None), + ('unpolarized', None), + ], + ) + def test_orso_header_detection(self, polarization, expected): + orso_dataset = SimpleNamespace( + info=SimpleNamespace( + data_source=SimpleNamespace( + measurement=SimpleNamespace( + instrument_settings=SimpleNamespace(polarization=polarization), + ) + ) + ) + ) + with patch('orsopy.fileio.orso.load_orso', return_value=[orso_dataset]): + assert detect_polarization_channel('whatever.ort') == expected + + def test_header_takes_precedence_over_filename(self): + orso_dataset = SimpleNamespace( + info=SimpleNamespace( + data_source=SimpleNamespace( + measurement=SimpleNamespace( + instrument_settings=SimpleNamespace(polarization='mm'), + ) + ) + ) + ) + with patch('orsopy.fileio.orso.load_orso', return_value=[orso_dataset]): + assert detect_polarization_channel('sample_uu.ort') == PolarizationChannel.MM + + def test_unreadable_file_falls_back_to_filename(self): + # No such file: the ORSO branch raises internally and the name decides. + assert detect_polarization_channel('no_such_file_dd.ort') == PolarizationChannel.MM + + def test_unpolarized_header_suppresses_filename_fallback(self): + # A header that explicitly declares a non-channel polarization wins over + # channel-looking filename tokens. + orso_dataset = SimpleNamespace( + info=SimpleNamespace( + data_source=SimpleNamespace( + measurement=SimpleNamespace( + instrument_settings=SimpleNamespace(polarization='unpolarized'), + ) + ) + ) + ) + with patch('orsopy.fileio.orso.load_orso', return_value=[orso_dataset]): + assert detect_polarization_channel('sample_uu.ort') is None diff --git a/tests/sample/elements/layers/test_layer_magnetism.py b/tests/sample/elements/layers/test_layer_magnetism.py new file mode 100644 index 00000000..a2932dec --- /dev/null +++ b/tests/sample/elements/layers/test_layer_magnetism.py @@ -0,0 +1,226 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +Tests for LayerMagnetism and magnetic-parameter plumbing through the calculators. +""" + +import numpy as np +import pytest +from numpy.testing import assert_allclose +from numpy.testing import assert_equal + +from easyreflectometry.calculators.factory import CalculatorFactory +from easyreflectometry.model import Model +from easyreflectometry.model import PercentageFwhm +from easyreflectometry.sample import Layer +from easyreflectometry.sample import LayerMagnetism +from easyreflectometry.sample import Material +from easyreflectometry.sample import Multilayer +from easyreflectometry.sample import Sample + +Q = np.linspace(0.005, 0.3, 50) + + +def _magnetic_model(magnetism: LayerMagnetism | None) -> Model: + vacuum = Material(sld=0, isld=0, name='Vacuum') + material = Material(sld=4.0, isld=0, name='Sld 4') + si = Material(sld=2.047, isld=0, name='Si') + superphase = Layer(material=vacuum, thickness=0, roughness=0, name='Vacuum Superphase') + layer = Layer(material=material, thickness=100, roughness=0, magnetism=magnetism, name='Sld 4 Layer') + subphase = Layer(material=si, thickness=0, roughness=0, name='Si Subphase') + sample = Sample(Multilayer(superphase), Multilayer(layer), Multilayer(subphase), name='Sample') + model = Model(sample=sample, scale=1, background=0, name='Magnetic Model') + model.resolution_function = PercentageFwhm(0) + return model + + +class TestLayerMagnetism: + def test_default_construction(self): + magnetism = LayerMagnetism() + assert_equal(magnetism.name, 'EasyLayerMagnetism') + assert_equal(magnetism.rho_m.value, 0.0) + assert_equal(magnetism.rho_m.fixed, True) + assert_equal(magnetism.theta_m.value, 270.0) + assert_equal(magnetism.theta_m.min, 0.0) + assert_equal(magnetism.theta_m.max, 360.0) + assert_equal(magnetism.theta_m.fixed, True) + + def test_construction_with_values(self): + magnetism = LayerMagnetism(rho_m=2.5, theta_m=45.0, name='FeMoment') + assert_equal(magnetism.name, 'FeMoment') + assert_equal(magnetism.rho_m.value, 2.5) + assert_equal(magnetism.theta_m.value, 45.0) + + def test_dict_round_trip(self): + magnetism = LayerMagnetism(rho_m=1.5, theta_m=90.0) + magnetism_dict = magnetism.as_dict() + reloaded = LayerMagnetism.from_dict(magnetism_dict) + assert_equal(reloaded.rho_m.value, 1.5) + assert_equal(reloaded.theta_m.value, 90.0) + assert sorted(magnetism.as_dict()) == sorted(reloaded.as_dict()) + + +class TestLayerWithMagnetism: + def test_layer_default_is_non_magnetic(self): + layer = Layer() + assert layer.magnetism is None + + def test_layer_dict_round_trip_with_magnetism(self): + layer = Layer(magnetism=LayerMagnetism(rho_m=2.0, theta_m=45.0), name='MagneticLayer') + layer_dict = layer.as_dict() + reloaded = Layer.from_dict(layer_dict) + assert reloaded.magnetism is not None + assert_equal(reloaded.magnetism.rho_m.value, 2.0) + assert_equal(reloaded.magnetism.theta_m.value, 45.0) + assert sorted(layer.as_dict()) == sorted(reloaded.as_dict()) + + def test_magnetic_parameters_are_fittable_variables(self): + magnetism = LayerMagnetism(rho_m=2.0, theta_m=45.0) + layer = Layer(magnetism=magnetism) + variable_names = [variable.name for variable in layer.get_all_variables()] + assert 'rho_m' in variable_names + assert 'theta_m' in variable_names + + +class TestMagnetismThroughCalculator: + def _interface(self, name: str) -> CalculatorFactory: + interface = CalculatorFactory() + interface.switch(name) + return interface + + def test_model_interface_enables_magnetism_and_binds_parameters(self): + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=45.0)) + interface = self._interface('refl1d') + model.interface = interface + calculator = interface() + + assert model.has_magnetism is True + assert calculator.include_magnetism is True + layer = model.sample[1].layers[0] + wrapper = calculator._wrapper + assert wrapper.get_layer_value(layer.unique_name, 'magnetism_rhoM') == 2.0 + assert wrapper.get_layer_value(layer.unique_name, 'magnetism_thetaM') == 45.0 + + def test_magnetic_parameter_change_changes_reflectivity(self): + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=45.0)) + model.interface = self._interface('refl1d') + + before = model.interface.polarized_reflectivity_profiles(Q, model.unique_name) + model.sample[1].layers[0].magnetism.rho_m = 4.0 + after = model.interface.polarized_reflectivity_profiles(Q, model.unique_name) + + assert not np.allclose(before['pp'], after['pp']) + assert not np.allclose(before['mm'], after['mm']) + + def test_model_parameters_match_direct_wrapper_values(self): + # Setting rho_m/theta_m through the model must reproduce the reflectivity + # obtained by setting magnetism_rhoM/thetaM directly on the wrapper. + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=45.0)) + model.interface = self._interface('refl1d') + via_model = model.interface.polarized_reflectivity_profiles(Q, model.unique_name) + + reference_model = _magnetic_model(None) + interface = self._interface('refl1d') + reference_model.interface = interface + calculator = interface() + calculator.include_magnetism = True + layer_name = reference_model.sample[1].layers[0].unique_name + calculator._wrapper.update_layer(layer_name, magnetism_rhoM=2.0, magnetism_thetaM=45.0) + via_wrapper = interface.polarized_reflectivity_profiles(Q, reference_model.unique_name) + + for channel in ('pp', 'pm', 'mp', 'mm'): + assert_allclose(via_model[channel], via_wrapper[channel], rtol=1e-10) + + def test_magnetism_added_after_interface_is_bound(self): + model = _magnetic_model(None) + model.interface = self._interface('refl1d') + calculator = model.interface() + assert calculator.include_magnetism is False + + layer = model.sample[1].layers[0] + layer.magnetism = LayerMagnetism(rho_m=2.0, theta_m=45.0) + + assert calculator.include_magnetism is True + wrapper = calculator._wrapper + assert wrapper.get_layer_value(layer.unique_name, 'magnetism_rhoM') == 2.0 + # The parameter is live: a change propagates to the backend. + layer.magnetism.rho_m = 3.0 + assert wrapper.get_layer_value(layer.unique_name, 'magnetism_rhoM') == 3.0 + + def test_removing_last_magnetism_disables_calculator_magnetism(self): + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=45.0)) + model.interface = self._interface('refl1d') + calculator = model.interface() + layer = model.sample[1].layers[0] + detached = layer.magnetism + + layer.magnetism = None + + # Model and calculator agree again: no magnetism anywhere. + assert layer.magnetism is None + assert model.has_magnetism is False + assert calculator.include_magnetism is False + assert calculator._wrapper.get_layer_value(layer.unique_name, 'magnetism_rhoM') == 0.0 + # The plain (unpolarized) calculation path works. + reflectivity = calculator.reflectity_profile(Q, model.unique_name) + assert len(reflectivity) == len(Q) + # The detached parameters no longer reach the backend. + detached.rho_m = 5.0 + assert calculator._wrapper.get_layer_value(layer.unique_name, 'magnetism_rhoM') == 0.0 + + def test_removing_one_of_two_magnetic_layers_keeps_magnetism(self): + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=45.0)) + model.interface = self._interface('refl1d') + calculator = model.interface() + subphase = model.sample[2].layers[0] + subphase.magnetism = LayerMagnetism(rho_m=1.0, theta_m=270.0) + + model.sample[1].layers[0].magnetism = None + + # One magnetic layer remains: the polarized path stays on, its values intact. + assert model.has_magnetism is True + assert calculator.include_magnetism is True + assert calculator._wrapper.get_layer_value(subphase.unique_name, 'magnetism_rhoM') == 1.0 + + def test_magnetic_layer_with_refnx_raises(self): + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=45.0)) + with pytest.raises(NotImplementedError): + model.interface = self._interface('refnx') + + def test_supports_magnetism_capability_flag(self): + assert self._interface('refl1d')().supports_magnetism is True + assert self._interface('refnx')().supports_magnetism is False + + def test_project_exposes_calculator_capability(self): + from easyreflectometry.project import Project + + project = Project() + project.calculator = 'refl1d' + assert project.calculator_supports_magnetism is True + project.calculator = 'refnx' + assert project.calculator_supports_magnetism is False + + def test_fit_recovers_rho_m_from_synthetic_data(self): + from easyreflectometry.data import DataSet1D + from easyreflectometry.fitting import MultiFitter + + # Synthesize noiseless pp data from a model with a known moment. + truth = _magnetic_model(LayerMagnetism(rho_m=2.5, theta_m=270.0)) + truth.interface = self._interface('refl1d') + reflectivity = truth.interface.polarized_reflectivity_profiles(Q, truth.unique_name)['pp'] + # `ye` holds variances. + data = DataSet1D(name='synthetic_pp', x=Q, y=reflectivity, ye=(0.01 * reflectivity) ** 2) + + # Fit a model starting from the wrong moment; only rho_m is free. + model = _magnetic_model(LayerMagnetism(rho_m=1.0, theta_m=270.0)) + model.interface = self._interface('refl1d') + rho_m = model.sample[1].layers[0].magnetism.rho_m + rho_m.fixed = False + rho_m.bounds = (0.0, 5.0) + + fitter = MultiFitter(model) + result = fitter.fit_single_data_set_1d(data) + + assert result.success + assert_allclose(rho_m.value, 2.5, atol=0.01) diff --git a/tests/summary/test_summary.py b/tests/summary/test_summary.py index a636b504..df1a7ac9 100644 --- a/tests/summary/test_summary.py +++ b/tests/summary/test_summary.py @@ -4,6 +4,7 @@ import os from unittest.mock import MagicMock +import numpy as np import pytest from easyscience import global_object @@ -151,6 +152,47 @@ def test_experiments_section_percentage_fhwm(self, project: Project) -> None: # Expect assert 'PercentageFwhm 5%' in html + def test_experiments_section_polarized(self, project: Project, tmp_path) -> None: + # When + # A polarized experiment holds one DataSet1D per spin channel, not the + # x/y arrays an ordinary experiment has — the section used to raise + # AttributeError, which killed the app when QML read the summary. + channel_paths = {} + for channel, suffix in (('pp', 'uu'), ('mm', 'dd')): + path = tmp_path / f'sample_{suffix}.txt' + q = np.linspace(0.01, 0.2, 20) + reflectivity = np.exp(-q * 30) + np.savetxt(path, np.column_stack([q, reflectivity, 0.01 * reflectivity])) + channel_paths[channel] = str(path) + project.calculator = 'refl1d' + project.load_polarized_experiment(channel_paths) + summary = Summary(project) + + # Then + html = summary._experiments_section() + + # Expect: one row per measured channel + assert 'Polarized experiment 0 (pp)' in html + assert 'Polarized experiment 0 (mm)' in html + assert html.count('No. of data points') == 2 + assert '20' in html + + def test_compile_html_summary_polarized(self, project: Project, tmp_path) -> None: + # When + channel_paths = {} + for channel, suffix in (('pp', 'uu'), ('mm', 'dd')): + path = tmp_path / f'sample_{suffix}.txt' + q = np.linspace(0.01, 0.2, 20) + reflectivity = np.exp(-q * 30) + np.savetxt(path, np.column_stack([q, reflectivity, 0.01 * reflectivity])) + channel_paths[channel] = str(path) + project.calculator = 'refl1d' + project.load_polarized_experiment(channel_paths) + summary = Summary(project) + + # Then Expect: the whole report compiles for a polarized experiment + assert 'Polarized experiment 0 (pp)' in summary.compile_html_summary() + def test_refinement_section(self, project: Project) -> None: # When summary = Summary(project) @@ -226,3 +268,84 @@ def test_figures_section_interactive(self, project: Project) -> None: # Two interactive plotly charts with the library embedded inline once. assert html.count('class="plotly-graph-div"') == 2 assert 'Plotly.newPlot' in html + + +class TestSummaryPolarized: + """Report figures of a polarized (per-channel) experiment.""" + + @staticmethod + def _write_channel_file(directory, name: str) -> str: + import numpy as np + + path = directory / name + q = np.linspace(0.01, 0.2, 20) + reflectivity = np.exp(-q * 30) + np.savetxt(path, np.column_stack([q, reflectivity, 0.01 * reflectivity])) + return str(path) + + @pytest.fixture + def polarized_project(self, tmp_path) -> Project: + global_object.map._clear() + project = Project() + project.calculator = 'refl1d' + project.default_model() + project.load_polarized_experiment({ + 'pp': self._write_channel_file(tmp_path, 'sample_uu.txt'), + 'mm': self._write_channel_file(tmp_path, 'sample_dd.txt'), + }) + return project + + def test_measured_series_one_entry_per_channel(self, polarized_project: Project) -> None: + # When + summary = Summary(polarized_project) + + # Then + series = summary._measured_series() + + # Expect + assert [entry[0] for entry in series] == ['Experiment (pp)', 'Experiment (mm)'] + assert series[0][2] != series[1][2] # distinct channel colors + assert [entry[3].value for entry in series] == ['pp', 'mm'] + + @pytest.fixture + def project(self) -> Project: + global_object.map._clear() + project = Project() + project.default_model() + return project + + def test_measured_series_unpolarized_and_empty(self, project: Project) -> None: + # When + summary = Summary(project) + + # Then Expect: nothing loaded + assert summary._measured_series() == [] + + # When an ordinary experiment is loaded + project.load_experiment_for_model_at_index(os.path.join(PATH_STATIC, 'example.ort')) + + # Expect one channel-less entry + series = summary._measured_series() + assert len(series) == 1 + assert series[0][0] == 'Experiment' and series[0][3] is None + + def test_model_curve_skips_uncalculable_channel(self, polarized_project: Project) -> None: + # When: a non-magnetic model cannot produce a spin-flip cross-section + summary = Summary(polarized_project) + + # Then Expect: no misleading overlay is generated for it + assert summary._model_curve('pm') is None + assert summary._model_curve('pp') is not None + + def test_fit_experiment_figure_has_a_trace_per_channel(self, polarized_project: Project) -> None: + # When + summary = Summary(polarized_project) + + # Then + figure = summary._fit_experiment_plotly_figure() + + # Expect: one measured trace per channel plus the calculable model curves + names = [trace.name for trace in figure.data] + assert 'Experiment (pp)' in names + assert 'Experiment (mm)' in names + assert 'Model (pp)' in names diff --git a/tests/test_limits.py b/tests/test_limits.py index 2fd1cc74..190b0d32 100644 --- a/tests/test_limits.py +++ b/tests/test_limits.py @@ -151,3 +151,54 @@ def test_existing_parameter_bounds_preserved(self): mat = Material(sld=custom_sld) assert mat.sld.min == -0.5 assert mat.sld.max == 7.0 + + +class TestMagneticParameterLimits: + def setup_method(self): + global_object.map._clear() + + def test_rho_m_uses_the_sld_window(self): + param = Parameter('rho_m', 5.0, min=-np.inf, max=np.inf) + apply_default_limits(param, 'rho_m') + assert param.min == SLD_LIMITS[0] + assert param.max == SLD_LIMITS[1] + + def test_magnetism_constructor_keeps_default_bounds_until_project_sync(self): + from easyreflectometry.sample import LayerMagnetism + + magnetism = LayerMagnetism(rho_m=5.0) + assert np.isinf(magnetism.rho_m.min) + assert np.isinf(magnetism.rho_m.max) + + def test_project_sync_narrows_rho_m_and_leaves_theta_m(self): + from easyreflectometry.project import Project + from easyreflectometry.sample import LayerMagnetism + + project = Project() + project.calculator = 'refl1d' + project.default_model() + layer = project.models[0].sample[1].layers[0] + layer.magnetism = LayerMagnetism(rho_m=5.0, theta_m=40.0) + + project._sync_parameter_states() + + assert layer.magnetism.rho_m.min == SLD_LIMITS[0] + assert layer.magnetism.rho_m.max == SLD_LIMITS[1] + # theta_m ships with explicit physical bounds; the sync must not touch them. + assert layer.magnetism.theta_m.min == 0.0 + assert layer.magnetism.theta_m.max == 360.0 + + def test_project_sync_keeps_explicit_rho_m_bounds(self): + from easyreflectometry.project import Project + from easyreflectometry.sample import LayerMagnetism + + project = Project() + project.calculator = 'refl1d' + project.default_model() + layer = project.models[0].sample[1].layers[0] + layer.magnetism = LayerMagnetism(rho_m=Parameter('rho_m', 5.0, min=1.0, max=8.0)) + + project._sync_parameter_states() + + assert layer.magnetism.rho_m.min == 1.0 + assert layer.magnetism.rho_m.max == 8.0 diff --git a/tests/test_polarized_fitting.py b/tests/test_polarized_fitting.py new file mode 100644 index 00000000..d84f536f --- /dev/null +++ b/tests/test_polarized_fitting.py @@ -0,0 +1,1210 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +Tests for explicit-channel calculation, the polarized reflectivity cache, +per-channel experiment loading, and simultaneous multi-channel fitting. +""" + +import json +from unittest.mock import patch + +import numpy as np +import pytest +from easyscience import global_object +from numpy.testing import assert_allclose + +from easyreflectometry.calculators import CalculatorFactory +from easyreflectometry.calculators import PolarizationChannel +from easyreflectometry.calculators.refl1d import wrapper as refl1d_wrapper +from easyreflectometry.data import DataSet1D +from easyreflectometry.data import PolarizedDataSet +from easyreflectometry.fitting import MultiFitter +from easyreflectometry.model import Model +from easyreflectometry.model import ModelCollection +from easyreflectometry.model import PercentageFwhm +from easyreflectometry.project import Project +from easyreflectometry.sample import Layer +from easyreflectometry.sample import LayerMagnetism +from easyreflectometry.sample import Material +from easyreflectometry.sample import Multilayer +from easyreflectometry.sample import Sample + +Q = np.linspace(0.005, 0.3, 50) + + +@pytest.fixture(autouse=True) +def _isolated_global_object(): + """Leave the easyscience object map clean for the next test file. + + Several tests here build a `Project` (and therefore a 'project_models' + collection); a leftover registration makes the *next* module's first + `Project()` fail with 'Object name project_models already exists'. + """ + global_object.map._clear() + yield + global_object.map._clear() + + +def _magnetic_model(magnetism: LayerMagnetism | None) -> Model: + vacuum = Material(sld=0, isld=0, name='Vacuum') + material = Material(sld=4.0, isld=0, name='Sld 4') + si = Material(sld=2.047, isld=0, name='Si') + superphase = Layer(material=vacuum, thickness=0, roughness=0, name='Vacuum Superphase') + layer = Layer(material=material, thickness=100, roughness=0, magnetism=magnetism, name='Sld 4 Layer') + subphase = Layer(material=si, thickness=0, roughness=0, name='Si Subphase') + sample = Sample(Multilayer(superphase), Multilayer(layer), Multilayer(subphase), name='Sample') + model = Model(sample=sample, scale=1, background=0, name='Magnetic Model') + model.resolution_function = PercentageFwhm(0) + return model + + +def _refl1d_interface() -> CalculatorFactory: + interface = CalculatorFactory() + interface.switch('refl1d') + return interface + + +def _polarized_data(channels: dict[str, np.ndarray], model=None) -> PolarizedDataSet: + datasets = { + channel: DataSet1D(name=channel, x=Q, y=reflectivity, ye=(0.01 * reflectivity) ** 2) + for channel, reflectivity in channels.items() + } + return PolarizedDataSet(name='synthetic', channels=datasets, model=model) + + +class TestCalculateChannel: + def test_channels_match_calculate_polarized(self): + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=45.0)) + model.interface = _refl1d_interface() + calculator = model.interface() + + reference = calculator.polarized_reflectivity_profiles(Q, model.unique_name) + for channel in ('pp', 'pm', 'mp', 'mm'): + assert_allclose( + calculator.reflectivity_profile_channel(Q, model.unique_name, channel), + reference[channel], + rtol=1e-12, + ) + + def test_pp_without_magnetism_falls_back_to_unpolarized(self): + model = _magnetic_model(None) + model.interface = _refl1d_interface() + calculator = model.interface() + + assert_allclose( + calculator.reflectivity_profile_channel(Q, model.unique_name, 'pp'), + calculator.reflectity_profile(Q, model.unique_name), + rtol=1e-12, + ) + + def test_spin_flip_without_magnetism_raises(self): + model = _magnetic_model(None) + model.interface = _refl1d_interface() + with pytest.raises(ValueError): + model.interface().reflectivity_profile_channel(Q, model.unique_name, 'pm') + + def test_fit_func_for_channel(self): + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=45.0)) + model.interface = _refl1d_interface() + + reference = model.interface.polarized_reflectivity_profiles(Q, model.unique_name) + fit_func = model.interface.fit_func_for_channel('mm') + assert_allclose(fit_func(Q, model.unique_name), reference['mm'], rtol=1e-12) + + def test_magnetism_inside_a_repeating_multilayer_raises_instead_of_silently_wrong(self): + """refl1d itself does not support repeated magnetic slabs (`profile.repeat`). + + `magnetism.ipynb` documents this as a known limitation; this test pins + that the failure is loud (`NotImplementedError`) rather than a silent + wrong answer, so the tutorial's claim stays checked against behaviour. + """ + from easyreflectometry.sample import RepeatingMultilayer + + vacuum = Material(sld=0, isld=0, name='Vacuum') + material = Material(sld=4.0, isld=0, name='Fe') + si = Material(sld=2.047, isld=0, name='Si') + superphase = Layer(material=vacuum, thickness=0, roughness=0, name='Vacuum Superphase') + layer = Layer( + material=material, + thickness=50, + roughness=2, + magnetism=LayerMagnetism(rho_m=5.0, theta_m=40.0), + name='Fe film', + ) + subphase = Layer(material=si, thickness=0, roughness=3, name='Si Subphase') + repeated = RepeatingMultilayer(layer, repetitions=3, name='Repeated Fe') + sample = Sample(Multilayer(superphase), repeated, Multilayer(subphase), name='Repeated magnetic sample') + model = Model(sample=sample, scale=1, background=0, name='Repeated magnetic model') + model.resolution_function = PercentageFwhm(0) + model.interface = _refl1d_interface() + + with pytest.raises(NotImplementedError, match='[Rr]epeat'): + model.interface().polarized_reflectivity_profiles(Q, model.unique_name) + + +class TestPolarizedCache: + def test_repeated_calculation_hits_cache(self): + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=45.0)) + model.interface = _refl1d_interface() + calculator = model.interface() + + with patch.object(refl1d_wrapper.names, 'Experiment', wraps=refl1d_wrapper.names.Experiment) as experiment: + first = calculator.polarized_reflectivity_profiles(Q, model.unique_name) + second = calculator.polarized_reflectivity_profiles(Q, model.unique_name) + assert experiment.call_count == 1 + # All four channels through calculate_channel: still no new evaluation. + for channel in ('pp', 'pm', 'mp', 'mm'): + calculator.reflectivity_profile_channel(Q, model.unique_name, channel) + assert experiment.call_count == 1 + for channel in ('pp', 'pm', 'mp', 'mm'): + assert_allclose(first[channel], second[channel], rtol=1e-15) + + def test_parameter_change_invalidates_cache(self): + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=45.0)) + model.interface = _refl1d_interface() + calculator = model.interface() + + with patch.object(refl1d_wrapper.names, 'Experiment', wraps=refl1d_wrapper.names.Experiment) as experiment: + before = calculator.polarized_reflectivity_profiles(Q, model.unique_name) + model.sample[1].layers[0].magnetism.rho_m = 3.0 + after = calculator.polarized_reflectivity_profiles(Q, model.unique_name) + assert experiment.call_count == 2 + assert not np.allclose(before['mm'], after['mm']) + + def test_q_dtype_is_normalized_before_keying(self): + # Keying happens after normalization to float64 plus explicit shape, so + # byte-identical arrays of different dtype/shape can never collide. A + # float32 grid whose values are exactly representable normalizes to the + # same key as its float64 twin and shares the cache entry. + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=45.0)) + model.interface = _refl1d_interface() + calculator = model.interface() + q_pow2 = np.array([0.03125, 0.0625, 0.125, 0.25]) # exact in float32 + + with patch.object(refl1d_wrapper.names, 'Experiment', wraps=refl1d_wrapper.names.Experiment) as experiment: + first = calculator.polarized_reflectivity_profiles(q_pow2, model.unique_name) + second = calculator.polarized_reflectivity_profiles(q_pow2.astype(np.float32), model.unique_name) + assert experiment.call_count == 1 + for channel in ('pp', 'pm', 'mp', 'mm'): + assert len(first[channel]) == len(q_pow2) + assert_allclose(second[channel], first[channel], rtol=1e-15) + + def test_different_q_grids_coexist_within_one_state(self): + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=45.0)) + model.interface = _refl1d_interface() + calculator = model.interface() + q_other = np.linspace(0.01, 0.2, 30) + + with patch.object(refl1d_wrapper.names, 'Experiment', wraps=refl1d_wrapper.names.Experiment) as experiment: + calculator.polarized_reflectivity_profiles(Q, model.unique_name) + calculator.polarized_reflectivity_profiles(q_other, model.unique_name) + assert experiment.call_count == 2 + # Both grids now cached for the same model state. + calculator.polarized_reflectivity_profiles(Q, model.unique_name) + calculator.polarized_reflectivity_profiles(q_other, model.unique_name) + assert experiment.call_count == 2 + + +class TestLoadPolarizedExperiment: + @staticmethod + def _write_channel_file(directory, name: str) -> str: + path = directory / name + q = np.linspace(0.01, 0.2, 20) + reflectivity = np.exp(-q * 30) + error = 0.01 * reflectivity + np.savetxt(path, np.column_stack([q, reflectivity, error])) + return str(path) + + def test_load_polarized_experiment(self, tmp_path): + pp_path = self._write_channel_file(tmp_path, 'sample_uu.txt') + mm_path = self._write_channel_file(tmp_path, 'sample_dd.txt') + + project = Project() + project.calculator = 'refl1d' + project.default_model() + + new_index = project.load_polarized_experiment({'pp': pp_path, 'mm': mm_path}) + + # The index is returned so a GUI can make the new experiment current. + assert new_index == 0 + experiment = project.experiments[0] + assert isinstance(experiment, PolarizedDataSet) + assert experiment.available_channels == [PolarizationChannel.PP, PolarizationChannel.MM] + assert experiment.name == 'Polarized experiment 0' + assert experiment.model is project.models[0] + assert experiment['pp'].model is project.models[0] + assert len(experiment['pp'].x) == 20 + + def test_second_polarized_experiment_gets_the_next_index(self, tmp_path): + pp_path = self._write_channel_file(tmp_path, 'sample_uu.txt') + mm_path = self._write_channel_file(tmp_path, 'sample_dd.txt') + + project = Project() + project.calculator = 'refl1d' + project.default_model() + + first = project.load_polarized_experiment({'pp': pp_path, 'mm': mm_path}) + second = project.load_polarized_experiment({'pp': pp_path}) + + assert (first, second) == (0, 1) + assert len(project.experiments) == 2 + + def test_multi_dataset_file_is_rejected(self, tmp_path): + import os + + multi_path = os.path.join(os.path.dirname(__file__), '_static', 'test_example2.ort') + mm_path = self._write_channel_file(tmp_path, 'sample_dd.txt') + + project = Project() + project.calculator = 'refl1d' + project.default_model() + + with pytest.raises(ValueError, match='multiple datasets'): + project.load_polarized_experiment({'pp': multi_path, 'mm': mm_path}) + assert len(project.experiments) == 0 + + def test_suggest_polarized_channel_assignment(self, tmp_path): + pp_path = self._write_channel_file(tmp_path, 'sample_uu.txt') + mm_path = self._write_channel_file(tmp_path, 'sample_dd.txt') + unknown_path = self._write_channel_file(tmp_path, 'sample_other.txt') + + project = Project() + suggestion = project.suggest_polarized_channel_assignment([pp_path, mm_path, unknown_path]) + + assert suggestion[str(pp_path)] == PolarizationChannel.PP + assert suggestion[str(mm_path)] == PolarizationChannel.MM + assert suggestion[str(unknown_path)] is None + + def test_project_as_dict_and_from_dict_round_trip_a_polarized_experiment(self, tmp_path): + pp_path = self._write_channel_file(tmp_path, 'sample_uu.txt') + mm_path = self._write_channel_file(tmp_path, 'sample_dd.txt') + + project = Project() + project.calculator = 'refl1d' + project.default_model() + project.load_polarized_experiment({'pp': pp_path, 'mm': mm_path}) + original = project.experiments[0] + + # Must not raise (PolarizedDataSet has no .x/.y/.ye of its own) and must + # be plain-JSON-serializable, since that is what `save_as_json` does with it. + project_dict = project.as_dict(include_materials_not_in_model=True) + json.dumps(project_dict) + + global_object.map._clear() + reloaded_project = Project() + reloaded_project.from_dict(project_dict) + reloaded = reloaded_project.experiments[0] + + assert isinstance(reloaded, PolarizedDataSet) + assert reloaded.name == original.name + assert reloaded.available_channels == original.available_channels + assert reloaded.model is reloaded_project.models[0] + for channel in original.available_channels: + assert reloaded[channel].name == original[channel].name + assert reloaded[channel].model is reloaded_project.models[0] + assert_allclose(reloaded[channel].x, original[channel].x) + assert_allclose(reloaded[channel].y, original[channel].y) + assert_allclose(reloaded[channel].ye, original[channel].ye) + + def test_project_save_as_json_and_load_from_json_round_trip_a_polarized_experiment(self, tmp_path): + pp_path = self._write_channel_file(tmp_path, 'sample_uu.txt') + mm_path = self._write_channel_file(tmp_path, 'sample_dd.txt') + + project = Project() + project.set_path_project_parent(tmp_path) + project.calculator = 'refl1d' + project.default_model() + project._info['name'] = 'Polarized round trip' + project.load_polarized_experiment({'pp': pp_path, 'mm': mm_path}) + + project.save_as_json() + assert project.path_json.exists() + + global_object.map._clear() + reloaded_project = Project() + reloaded_project.load_from_json(project.path_json) + reloaded = reloaded_project.experiments[0] + + assert isinstance(reloaded, PolarizedDataSet) + assert reloaded.available_channels == [PolarizationChannel.PP, PolarizationChannel.MM] + + +class TestChannelAwareExperimentAccessors: + """`experimental_data_for_model_at_index(index, channel=…)` and friends.""" + + @staticmethod + def _polarized_project(tmp_path) -> Project: + pp_path = TestLoadPolarizedExperiment._write_channel_file(tmp_path, 'sample_uu.txt') + mm_path = TestLoadPolarizedExperiment._write_channel_file(tmp_path, 'sample_dd.txt') + project = Project() + project.calculator = 'refl1d' + project.default_model() + project.load_polarized_experiment({'pp': pp_path, 'mm': mm_path}) + return project + + def test_without_channel_returns_the_whole_experiment(self, tmp_path): + project = self._polarized_project(tmp_path) + + experiment = project.experimental_data_for_model_at_index(0) + + assert isinstance(experiment, PolarizedDataSet) + assert project.experiment_is_polarized_at_index(0) is True + assert project.experiment_channels_at_index(0) == [PolarizationChannel.PP, PolarizationChannel.MM] + + def test_channel_returns_that_channel_dataset(self, tmp_path): + project = self._polarized_project(tmp_path) + experiment = project.experiments[0] + + for channel in ('pp', PolarizationChannel.MM): + data = project.experimental_data_for_model_at_index(0, channel=channel) + assert isinstance(data, DataSet1D) + assert data is experiment[channel] + + def test_unmeasured_channel_raises_key_error(self, tmp_path): + project = self._polarized_project(tmp_path) + + with pytest.raises(KeyError, match='was not measured'): + project.experimental_data_for_model_at_index(0, channel='pm') + + def test_unknown_channel_raises_value_error(self, tmp_path): + project = self._polarized_project(tmp_path) + + with pytest.raises(ValueError, match='Unknown spin channel'): + project.experimental_data_for_model_at_index(0, channel='xx') + + def test_channel_on_unpolarized_experiment_raises_value_error(self, tmp_path): + path = TestLoadPolarizedExperiment._write_channel_file(tmp_path, 'sample.txt') + project = Project() + project.calculator = 'refl1d' + project.default_model() + project.load_experiment_for_model_at_index(path, 0) + + assert project.experiment_is_polarized_at_index(0) is False + assert project.experiment_channels_at_index(0) == [] + with pytest.raises(ValueError, match='not polarized'): + project.experimental_data_for_model_at_index(0, channel='pp') + + def test_missing_experiment_raises_index_error(self): + project = Project() + project.default_model() + + assert project.experiment_is_polarized_at_index(0) is False + with pytest.raises(IndexError): + project.experimental_data_for_model_at_index(0, channel='pp') + + def test_model_data_per_channel_differs_for_magnetic_model(self): + global_object.map._clear() + model = _magnetic_model(LayerMagnetism(rho_m=2.5, theta_m=40.0)) + project = Project() + project.calculator = 'refl1d' + project.models = ModelCollection(model) + q_range = np.linspace(0.01, 0.2, 25) + + pp = project.model_data_for_model_at_index(0, q_range=q_range, channel='pp') + mm = project.model_data_for_model_at_index(0, q_range=q_range, channel='mm') + pm = project.model_data_for_model_at_index(0, q_range=q_range, channel='pm') + + assert pp.name.endswith('(pp) for Model 0') + # Each cross-section is genuinely different — this is what a per-channel + # display/report must show instead of one curve repeated four times. + assert not np.allclose(pp.y, mm.y) + assert not np.allclose(pp.y, pm.y) + + def test_spin_flip_channel_of_non_magnetic_model_raises(self): + global_object.map._clear() + project = Project() + project.calculator = 'refl1d' + project.default_model() + + with pytest.raises(ValueError, match='requires magnetism'): + project.model_data_for_model_at_index(0, channel='pm') + + +class TestFitPolarized: + def test_two_channel_nsf_fit_recovers_rho_m(self): + truth = _magnetic_model(LayerMagnetism(rho_m=2.5, theta_m=270.0)) + truth.interface = _refl1d_interface() + reference = truth.interface.polarized_reflectivity_profiles(Q, truth.unique_name) + + model = _magnetic_model(LayerMagnetism(rho_m=1.0, theta_m=270.0)) + model.interface = _refl1d_interface() + rho_m = model.sample[1].layers[0].magnetism.rho_m + rho_m.fixed = False + rho_m.bounds = (0.0, 5.0) + + data = _polarized_data({'pp': reference['pp'], 'mm': reference['mm']}, model=model) + fitter = MultiFitter(model) + results = fitter.fit_polarized(data) + + assert list(results.keys()) == ['pp', 'mm'] + assert all(result.success for result in results.values()) + assert_allclose(rho_m.value, 2.5, atol=0.01) + + def test_four_channel_fit_recovers_rho_m_and_theta_m(self): + truth = _magnetic_model(LayerMagnetism(rho_m=2.5, theta_m=45.0)) + truth.interface = _refl1d_interface() + reference = truth.interface.polarized_reflectivity_profiles(Q, truth.unique_name) + + model = _magnetic_model(LayerMagnetism(rho_m=1.5, theta_m=60.0)) + model.interface = _refl1d_interface() + magnetism = model.sample[1].layers[0].magnetism + magnetism.rho_m.fixed = False + magnetism.rho_m.bounds = (0.0, 5.0) + magnetism.theta_m.fixed = False + magnetism.theta_m.bounds = (0.0, 90.0) + + data = _polarized_data(dict(reference), model=model) + fitter = MultiFitter(model) + results = fitter.fit_polarized(data) + + assert list(results.keys()) == ['pp', 'pm', 'mp', 'mm'] + assert all(result.success for result in results.values()) + assert_allclose(magnetism.rho_m.value, 2.5, atol=0.02) + assert_allclose(magnetism.theta_m.value, 45.0, atol=0.5) + + def test_shared_structural_parameter_fitted_across_channels(self): + truth = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=270.0)) + truth.interface = _refl1d_interface() + reference = truth.interface.polarized_reflectivity_profiles(Q, truth.unique_name) + + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=270.0)) + model.interface = _refl1d_interface() + thickness = model.sample[1].layers[0].thickness + thickness.value = 90.0 + thickness.fixed = False + thickness.bounds = (50.0, 150.0) + + data = _polarized_data({'pp': reference['pp'], 'mm': reference['mm']}, model=model) + fitter = MultiFitter(model) + results = fitter.fit_polarized(data) + + assert all(result.success for result in results.values()) + assert_allclose(thickness.value, 100.0, atol=0.1) + + def test_fit_polarized_requires_matching_model(self): + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=270.0)) + model.interface = _refl1d_interface() + other = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=270.0)) + other.interface = _refl1d_interface() + + data = _polarized_data({'pp': np.ones_like(Q)}, model=other) + fitter = MultiFitter(model) + with pytest.raises(ValueError, match='must be the model'): + fitter.fit_polarized(data) + + def test_fit_polarized_requires_matching_channel_models(self): + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=270.0)) + model.interface = _refl1d_interface() + other = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=270.0)) + other.interface = _refl1d_interface() + + data = _polarized_data({'pp': np.ones_like(Q), 'mm': np.ones_like(Q)}, model=model) + # Rebind one channel dataset behind the experiment's back. + data['mm'].model = other + + fitter = MultiFitter(model) + with pytest.raises(ValueError, match="'mm' channel dataset"): + fitter.fit_polarized(data) + + def test_fit_polarized_requires_single_model(self): + model_a = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=270.0)) + model_b = _magnetic_model(None) + interface = _refl1d_interface() + model_a.interface = interface + model_b.interface = interface + + data = _polarized_data({'pp': np.ones_like(Q)}, model=model_a) + fitter = MultiFitter(model_a, model_b) + with pytest.raises(ValueError): + fitter.fit_polarized(data) + + +class TestMultiFitterForExperiments: + """`MultiFitter.for_experiments` — one fit function per dataset, channels expanded.""" + + @staticmethod + def _unpolarized_data(model, name='plain') -> DataSet1D: + reflectivity = np.exp(-Q * 30) + dataset = DataSet1D(name=name, x=Q, y=reflectivity, ye=(0.01 * reflectivity) ** 2) + dataset.model = model + return dataset + + def test_polarized_experiment_expands_to_one_function_per_channel(self): + model = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=45.0)) + model.interface = _refl1d_interface() + reference = model.interface.polarized_reflectivity_profiles(Q, model.unique_name) + data = _polarized_data(dict(reference), model=model) + + fitter = MultiFitter.for_experiments([data]) + + assert fitter.fit_channels == [ + PolarizationChannel.PP, + PolarizationChannel.PM, + PolarizationChannel.MP, + PolarizationChannel.MM, + ] + assert fitter.fit_datasets == [data[channel] for channel in data.available_channels] + assert len(fitter._fit_func) == 4 + # Each function evaluates its own cross-section, not four copies of one. + curves = [func(Q) for func in fitter._fit_func] + for index, channel in enumerate(data.available_channels): + assert_allclose(curves[index], reference[channel.value], rtol=1e-9) + + def test_unpolarized_experiment_keeps_one_function(self): + model = _magnetic_model(None) + model.interface = _refl1d_interface() + data = self._unpolarized_data(model) + + fitter = MultiFitter.for_experiments([data]) + + assert fitter.fit_channels == [None] + assert fitter.fit_datasets == [data] + assert_allclose(fitter._fit_func[0](Q), model.interface.fit_func(Q, model.unique_name), rtol=1e-9) + + def test_mixed_experiments_share_one_fitter(self): + """A polarized and an ordinary experiment fitted together, two models.""" + magnetic = _magnetic_model(LayerMagnetism(rho_m=2.0, theta_m=270.0)) + plain = _magnetic_model(None) + interface = _refl1d_interface() + magnetic.interface = interface + plain.interface = interface + reference = magnetic.interface.polarized_reflectivity_profiles(Q, magnetic.unique_name) + polarized = _polarized_data({'pp': reference['pp'], 'mm': reference['mm']}, model=magnetic) + unpolarized = self._unpolarized_data(plain) + + fitter = MultiFitter.for_experiments([polarized, unpolarized]) + + assert fitter.fit_channels == [PolarizationChannel.PP, PolarizationChannel.MM, None] + # Both models' parameters are enumerated, so they are fitted together. + assert len(fitter._models) == 2 + assert len(fitter.easy_science_multi_fitter._fit_functions) == 3 + + def test_repeated_model_is_registered_once(self): + model = _magnetic_model(None) + model.interface = _refl1d_interface() + first = self._unpolarized_data(model, name='a') + second = self._unpolarized_data(model, name='b') + + fitter = MultiFitter.for_experiments([first, second]) + + assert len(fitter._models) == 1 + assert len(fitter.fit_datasets) == 2 + + def test_experiment_without_model_is_rejected(self): + dataset = DataSet1D(name='orphan', x=Q, y=np.ones_like(Q), ye=np.ones_like(Q)) + dataset.model = None + + with pytest.raises(ValueError, match='no model'): + MultiFitter.for_experiments([dataset]) + + def test_empty_experiment_list_is_rejected(self): + with pytest.raises(ValueError, match='At least one experiment'): + MultiFitter.for_experiments([]) + + def test_prepared_fitter_recovers_rho_m_when_run(self): + """The fitter is usable exactly like `fit_polarized`, but caller-driven.""" + truth = _magnetic_model(LayerMagnetism(rho_m=2.5, theta_m=270.0)) + truth.interface = _refl1d_interface() + reference = truth.interface.polarized_reflectivity_profiles(Q, truth.unique_name) + + model = _magnetic_model(LayerMagnetism(rho_m=1.0, theta_m=270.0)) + model.interface = _refl1d_interface() + rho_m = model.sample[1].layers[0].magnetism.rho_m + rho_m.fixed = False + rho_m.bounds = (0.0, 5.0) + data = _polarized_data({'pp': reference['pp'], 'mm': reference['mm']}, model=model) + + fitter = MultiFitter.for_experiments([data]) + x = [np.asarray(dataset.x) for dataset in fitter.fit_datasets] + y = [np.asarray(dataset.y) for dataset in fitter.fit_datasets] + weights = [1.0 / np.sqrt(np.asarray(dataset.ye)) for dataset in fitter.fit_datasets] + results = fitter.easy_science_multi_fitter.fit(x, y, weights=weights) + + assert all(result.success for result in results) + assert_allclose(rho_m.value, 2.5, atol=0.01) + + +class TestRecordFitResults: + """Results produced by a caller-driven fit can be handed back to the fitter.""" + + def _fitter_and_results(self): + model = _magnetic_model(None) + model.interface = _refl1d_interface() + reflectivity = np.exp(-Q * 30) + dataset = DataSet1D(name='plain', x=Q, y=reflectivity, ye=(0.01 * reflectivity) ** 2) + dataset.model = model + fitter = MultiFitter.for_experiments([dataset]) + results = fitter.easy_science_multi_fitter.fit( + [np.asarray(dataset.x)], [np.asarray(dataset.y)], weights=[1.0 / np.sqrt(np.asarray(dataset.ye))] + ) + return fitter, list(results) + + def test_metrics_are_none_before_recording(self): + fitter, _results = self._fitter_and_results() + + # `easy_science_multi_fitter.fit` bypasses MultiFitter entirely. + assert fitter.chi2 is None + assert fitter.reduced_chi is None + + def test_recording_makes_the_metrics_available(self): + fitter, results = self._fitter_and_results() + + fitter.record_fit_results(results) + + assert fitter.chi2 == pytest.approx(sum(r.chi2 for r in results)) + assert fitter.reduced_chi is not None + # The classical metrics need the original arrays, which FitResults lacks. + assert fitter.classical_chi2 is None + + def test_recording_none_clears_the_metrics(self): + fitter, results = self._fitter_and_results() + fitter.record_fit_results(results) + + fitter.record_fit_results(None) + + assert fitter.chi2 is None + + +class TestMagneticSldData: + """`Project.magnetic_sld_data_for_model_at_index` (Phase 5a).""" + + @staticmethod + def _magnetic_project(theta_m: float = 270.0) -> Project: + global_object.map._clear() + model = _magnetic_model(LayerMagnetism(rho_m=2.5, theta_m=theta_m)) + project = Project() + project.calculator = 'refl1d' + project.models = ModelCollection(model) + return project + + def test_profiles_and_spin_potentials(self): + project = self._magnetic_project() + + profiles = project.magnetic_sld_data_for_model_at_index(0) + + assert set(profiles) == {'sld', 'rho_m', 'theta_m', 'spin_up', 'spin_down'} + sld, rho_m = profiles['sld'], profiles['rho_m'] + assert sld.x.size == rho_m.x.size and sld.x.size > 0 + assert np.allclose(profiles['spin_up'].x, sld.x) + # theta_m = 270 == the guide field, so the projection is the full rho_m: + # spin-up sees rho + rho_m, spin-down rho - rho_m. + assert_allclose(profiles['spin_up'].y, sld.y + rho_m.y, atol=1e-10) + assert_allclose(profiles['spin_down'].y, sld.y - rho_m.y, atol=1e-10) + # The magnetic layer really is magnetic somewhere along z. + assert np.abs(rho_m.y).max() > 0 + + def test_canted_moment_reduces_the_split(self): + along_field = self._magnetic_project(theta_m=270.0).magnetic_sld_data_for_model_at_index(0) + canted = self._magnetic_project(theta_m=270.0 - 60.0).magnetic_sld_data_for_model_at_index(0) + + split_along = np.abs(along_field['spin_up'].y - along_field['spin_down'].y).max() + split_canted = np.abs(canted['spin_up'].y - canted['spin_down'].y).max() + + # cos(60 deg) = 0.5 of the moment is seen by the spin states. + assert_allclose(split_canted, split_along * 0.5, rtol=1e-6) + + def test_nuclear_profile_matches_the_ordinary_sld_curve(self): + project = self._magnetic_project() + + profiles = project.magnetic_sld_data_for_model_at_index(0) + nuclear = project.sld_data_for_model_at_index(0) + + assert_allclose(profiles['sld'].x, nuclear.x) + assert_allclose(profiles['sld'].y, nuclear.y) + + def test_non_magnetic_model_raises_and_reports_no_magnetism(self): + global_object.map._clear() + project = Project() + project.calculator = 'refl1d' + project.default_model() + + assert project.model_has_magnetism_at_index(0) is False + assert project.model_has_magnetism_at_index(7) is False + with pytest.raises(ValueError, match='no magnetic layer'): + project.magnetic_sld_data_for_model_at_index(0) + + +class TestSpinAsymmetry: + """`Project.spin_asymmetry_for_experiment_at_index` (Phase 5b).""" + + @staticmethod + def _project_with_channels(channels: dict, model=None) -> Project: + project = Project() + project.calculator = 'refl1d' + project.models = ModelCollection(model if model is not None else _magnetic_model(None)) + data = _polarized_data(channels, model=project.models[0]) + project._experiments[0] = data + return project + + def test_asymmetry_and_error_propagation(self): + global_object.map._clear() + r_pp = np.full_like(Q, 0.6) + r_mm = np.full_like(Q, 0.2) + project = self._project_with_channels({'pp': r_pp, 'mm': r_mm}) + + result = project.spin_asymmetry_for_experiment_at_index(0) + measured = result['measured'] + + # (0.6 - 0.2) / 0.8 + assert_allclose(measured.y, 0.5) + # ye holds variances: sigma_SA = 2 sqrt(R--^2 s++^2 + R++^2 s--^2)/(R+++R--)^2 + var_pp = (0.01 * r_pp) ** 2 + var_mm = (0.01 * r_mm) ** 2 + expected = 4.0 * (r_mm**2 * var_pp + r_pp**2 * var_mm) / (r_pp + r_mm) ** 4 + assert_allclose(measured.ye, expected, rtol=1e-12) + assert result['masked_points'] == 0 + + def test_insignificant_points_are_dropped_and_counted(self): + global_object.map._clear() + r_pp = np.full_like(Q, 0.6) + r_mm = np.full_like(Q, 0.2) + # Make the last five points pure noise: huge uncertainty, tiny signal. + r_pp[-5:] = 1e-9 + r_mm[-5:] = 1e-9 + noisy = np.where(np.arange(Q.size) >= Q.size - 5, 1.0, 1e-12) + datasets = { + 'pp': DataSet1D(name='pp', x=Q, y=r_pp, ye=noisy), + 'mm': DataSet1D(name='mm', x=Q, y=r_mm, ye=noisy), + } + project = Project() + project.calculator = 'refl1d' + project.models = ModelCollection(_magnetic_model(None)) + project._experiments[0] = PolarizedDataSet(name='noisy', channels=datasets, model=project.models[0]) + + result = project.spin_asymmetry_for_experiment_at_index(0) + + assert result['masked_points'] == 5 + assert result['measured'].x.size == Q.size - 5 + assert np.all(np.isfinite(result['measured'].y)) + + def test_channels_on_different_q_grids_are_interpolated(self): + global_object.map._clear() + project = Project() + project.calculator = 'refl1d' + project.models = ModelCollection(_magnetic_model(None)) + q_mm = Q + 0.001 + datasets = { + 'pp': DataSet1D(name='pp', x=Q, y=np.full_like(Q, 0.6), ye=np.full_like(Q, 1e-12)), + 'mm': DataSet1D(name='mm', x=q_mm, y=np.full_like(q_mm, 0.2), ye=np.full_like(q_mm, 1e-12)), + } + project._experiments[0] = PolarizedDataSet(name='shifted', channels=datasets, model=project.models[0]) + + result = project.spin_asymmetry_for_experiment_at_index(0) + measured = result['measured'] + + # SA lives on the pp grid, restricted to where mm has data; the constant + # mm channel interpolates to 0.2 there. + assert_allclose(measured.x, Q[Q >= q_mm.min()]) + assert result['out_of_overlap_points'] == int(np.count_nonzero(Q < q_mm.min())) + assert_allclose(measured.y, 0.5, atol=1e-9) + + def test_calculated_asymmetry_only_for_a_magnetic_model(self): + global_object.map._clear() + magnetic = _magnetic_model(LayerMagnetism(rho_m=2.5, theta_m=270.0)) + magnetic.interface = _refl1d_interface() + reference = magnetic.interface.polarized_reflectivity_profiles(Q, magnetic.unique_name) + project = self._project_with_channels({'pp': reference['pp'], 'mm': reference['mm']}, model=magnetic) + + result = project.spin_asymmetry_for_experiment_at_index(0) + + calculated = result['calculated'] + assert calculated is not None + # Data and model are the same sample, so the two SA curves agree. + assert_allclose(calculated.y, result['measured'].y, atol=1e-6) + assert np.abs(calculated.y).max() > 0.01 # a real magnetic signal + + def test_no_calculated_asymmetry_without_magnetism(self): + global_object.map._clear() + project = self._project_with_channels({'pp': np.full_like(Q, 0.6), 'mm': np.full_like(Q, 0.2)}) + + assert project.spin_asymmetry_for_experiment_at_index(0)['calculated'] is None + + def test_availability_and_errors(self): + global_object.map._clear() + project = self._project_with_channels({'pp': np.full_like(Q, 0.6), 'mm': np.full_like(Q, 0.2)}) + + assert project.experiment_supports_spin_asymmetry_at_index(0) is True + assert project.experiment_supports_spin_asymmetry_at_index(1) is False + + # pp only: no asymmetry to form. + global_object.map._clear() + nsf_incomplete = self._project_with_channels({'pp': np.full_like(Q, 0.6)}) + assert nsf_incomplete.experiment_supports_spin_asymmetry_at_index(0) is False + with pytest.raises(ValueError, match='both non-spin-flip channels'): + nsf_incomplete.spin_asymmetry_for_experiment_at_index(0) + with pytest.raises(IndexError): + project.spin_asymmetry_for_experiment_at_index(3) + + +class TestMagneticProfileSmoothing: + """CR1 M1: the profile must interpolate the moment as a vector.""" + + @staticmethod + def _two_layer_model(theta_top: float, theta_bottom: float) -> Model: + vacuum = Material(sld=0, isld=0, name='Vacuum') + iron = Material(sld=8.0, isld=0, name='Fe') + si = Material(sld=2.047, isld=0, name='Si') + superphase = Layer(material=vacuum, thickness=0, roughness=0, name='Vacuum Superphase') + top = Layer( + material=iron, + thickness=100, + roughness=5, + magnetism=LayerMagnetism(rho_m=5.0, theta_m=theta_top), + name='Fe top', + ) + bottom = Layer( + material=iron, + thickness=100, + roughness=5, + magnetism=LayerMagnetism(rho_m=5.0, theta_m=theta_bottom), + name='Fe bottom', + ) + subphase = Layer(material=si, thickness=0, roughness=5, name='Si Subphase') + sample = Sample(Multilayer(superphase), Multilayer(top), Multilayer(bottom), Multilayer(subphase), name='Sample') + model = Model(sample=sample, scale=1, background=0, name='Two-layer magnetic') + model.resolution_function = PercentageFwhm(0) + return model + + def _profiles(self, theta_top: float, theta_bottom: float) -> dict: + global_object.map._clear() + project = Project() + project.calculator = 'refl1d' + project.models = ModelCollection(self._two_layer_model(theta_top, theta_bottom)) + return project.magnetic_sld_data_for_model_at_index(0) + + def test_interface_between_almost_antiparallel_angles_does_not_invent_splitting(self): + # 359 deg and 1 deg are 2 deg apart, but smoothing the *angle* takes the + # long way round through 180 deg — and through the guide-field direction + # at 270 deg, where the full moment would look longitudinal. + profiles = self._profiles(359.0, 1.0) + + splitting = np.abs(profiles['spin_up'].y - profiles['spin_down'].y) + + # 2 * 5.0 * |cos(89 deg)| ~ 0.18, not 2 * 5.0. + assert splitting.max() < 0.5 + + def test_collinear_layers_keep_the_full_splitting(self): + # The same geometry with both moments along the guide field must still + # show the whole moment: the fix must not damp real magnetism. + profiles = self._profiles(270.0, 270.0) + + splitting = np.abs(profiles['spin_up'].y - profiles['spin_down'].y) + + assert splitting.max() == pytest.approx(2 * 5.0, rel=1e-3) + + def test_magnitude_and_angle_round_trip(self): + profiles = self._profiles(210.0, 210.0) + + rho_m = profiles['rho_m'] + theta_m = profiles['theta_m'] + + assert rho_m.y.max() == pytest.approx(5.0, rel=1e-3) + # theta_m is reported only where there is a moment. + assert theta_m.x.size < rho_m.x.size + assert np.allclose(theta_m.y, 210.0, atol=1e-6) + + def test_angle_is_not_reported_through_non_magnetic_regions(self): + profiles = self._profiles(270.0, 270.0) + + theta_m = profiles['theta_m'] + rho_m = profiles['rho_m'] + + # Every reported angle sits at a depth that carries a moment. + carried = np.interp(theta_m.x, rho_m.x, rho_m.y) + assert np.all(np.abs(carried) > 0) + assert theta_m.x.size > 0 + + +class TestSpinAsymmetryGridPairing: + """CR1 M2: pair channels only where both were measured.""" + + @staticmethod + def _project_with(pp: DataSet1D, mm: DataSet1D) -> Project: + global_object.map._clear() + project = Project() + project.calculator = 'refl1d' + project.models = ModelCollection(_magnetic_model(None)) + project._experiments[0] = PolarizedDataSet(name='pairing', channels={'pp': pp, 'mm': mm}, model=project.models[0]) + return project + + def test_points_outside_the_mm_range_are_dropped_not_extrapolated(self): + # pp reaches further in q than mm; np.interp would clamp to the mm edge + # value and present the result as measured data. + q_pp = np.linspace(0.01, 0.30, 30) + q_mm = np.linspace(0.01, 0.20, 20) + pp = DataSet1D(name='pp', x=q_pp, y=np.full_like(q_pp, 0.6), ye=np.full_like(q_pp, 1e-12)) + mm = DataSet1D(name='mm', x=q_mm, y=np.full_like(q_mm, 0.2), ye=np.full_like(q_mm, 1e-12)) + project = self._project_with(pp, mm) + + result = project.spin_asymmetry_for_experiment_at_index(0) + + assert result['out_of_overlap_points'] == int(np.count_nonzero(q_pp > 0.20)) + assert result['measured'].x.max() <= 0.20 + assert np.allclose(result['measured'].y, 0.5) + + def test_disjoint_grids_give_no_asymmetry(self): + q_pp = np.linspace(0.30, 0.40, 10) + q_mm = np.linspace(0.01, 0.20, 10) + pp = DataSet1D(name='pp', x=q_pp, y=np.full_like(q_pp, 0.6), ye=np.full_like(q_pp, 1e-12)) + mm = DataSet1D(name='mm', x=q_mm, y=np.full_like(q_mm, 0.2), ye=np.full_like(q_mm, 1e-12)) + project = self._project_with(pp, mm) + + result = project.spin_asymmetry_for_experiment_at_index(0) + + assert result['out_of_overlap_points'] == q_pp.size + assert result['measured'].x.size == 0 + assert result['calculated'] is None + + def test_a_float_round_trip_difference_still_counts_as_the_same_grid(self): + q = np.linspace(0.01, 0.2, 20) + # The kind of difference a text round-trip introduces. + q_mm = np.array([float(f'{value:.12g}') for value in q * (1 + 1e-12)]) + pp = DataSet1D(name='pp', x=q, y=np.full_like(q, 0.6), ye=np.full_like(q, 1e-12)) + mm = DataSet1D(name='mm', x=q_mm, y=np.full_like(q_mm, 0.2), ye=np.full_like(q_mm, 1e-12)) + project = self._project_with(pp, mm) + + result = project.spin_asymmetry_for_experiment_at_index(0) + + assert result['out_of_overlap_points'] == 0 + assert result['measured'].x.size == q.size + + def test_equal_grids_report_no_dropped_points(self): + q = np.linspace(0.01, 0.2, 20) + pp = DataSet1D(name='pp', x=q, y=np.full_like(q, 0.6), ye=np.full_like(q, 1e-12)) + mm = DataSet1D(name='mm', x=q, y=np.full_like(q, 0.2), ye=np.full_like(q, 1e-12)) + project = self._project_with(pp, mm) + + result = project.spin_asymmetry_for_experiment_at_index(0) + + assert result['out_of_overlap_points'] == 0 + assert result['masked_points'] == 0 + + +class TestSpinAsymmetryDenominatorGuards: + """CR2: the guard must not depend on the data carrying uncertainties.""" + + @staticmethod + def _project_with(pp: DataSet1D, mm: DataSet1D) -> Project: + global_object.map._clear() + project = Project() + project.calculator = 'refl1d' + project.models = ModelCollection(_magnetic_model(None)) + project._experiments[0] = PolarizedDataSet(name='guards', channels={'pp': pp, 'mm': mm}, model=project.models[0]) + return project + + def test_cancellation_without_uncertainties_is_dropped(self): + # Background-subtracted tail: the two channels nearly cancel, so SA is + # a ratio of rounding noise (here it would be 1999). + q = np.linspace(0.01, 0.2, 5) + pp = DataSet1D(name='pp', x=q, y=np.full_like(q, 1e-300)) + mm = DataSet1D(name='mm', x=q, y=np.full_like(q, -0.999e-300)) + project = self._project_with(pp, mm) + + result = project.spin_asymmetry_for_experiment_at_index(0) + + assert result['measured'].x.size == 0 + assert result['small_denominator_points'] == q.size + assert result['masked_points'] == q.size + + def test_ordinary_data_without_uncertainties_is_kept(self): + # A two-column file must still produce its asymmetry. + q = np.linspace(0.01, 0.2, 5) + pp = DataSet1D(name='pp', x=q, y=np.full_like(q, 0.6)) + mm = DataSet1D(name='mm', x=q, y=np.full_like(q, 0.2)) + project = self._project_with(pp, mm) + + result = project.spin_asymmetry_for_experiment_at_index(0) + + assert result['measured'].x.size == q.size + assert_allclose(result['measured'].y, 0.5) + assert result['masked_points'] == 0 + + def test_exact_cancellation_is_dropped(self): + q = np.linspace(0.01, 0.2, 4) + pp = DataSet1D(name='pp', x=q, y=np.full_like(q, 0.5)) + mm = DataSet1D(name='mm', x=q, y=np.full_like(q, -0.5)) + project = self._project_with(pp, mm) + + result = project.spin_asymmetry_for_experiment_at_index(0) + + assert result['measured'].x.size == 0 + assert result['small_denominator_points'] == q.size + + def test_unusable_variances_do_not_silently_become_no_uncertainty(self): + q = np.linspace(0.01, 0.2, 4) + variance = np.array([1e-12, -1.0, np.nan, 1e-12]) + pp = DataSet1D(name='pp', x=q, y=np.full_like(q, 0.6), ye=variance) + mm = DataSet1D(name='mm', x=q, y=np.full_like(q, 0.2), ye=np.full_like(q, 1e-12)) + project = self._project_with(pp, mm) + + result = project.spin_asymmetry_for_experiment_at_index(0) + + # The two malformed points are dropped and counted; the rest survive. + assert result['invalid_points'] == 2 + assert result['measured'].x.size == 2 + assert np.all(result['measured'].ye >= 0) + + def test_low_significance_and_cancellation_are_counted_separately(self): + q = np.linspace(0.01, 0.2, 6) + # First three: small but clean signal drowned in uncertainty. + # Last three: clean cancellation with negligible uncertainty. + r_pp = np.array([1e-6, 1e-6, 1e-6, 1.0, 1.0, 1.0]) + r_mm = np.array([1e-6, 1e-6, 1e-6, -0.9999, -0.9999, -0.9999]) + variance = np.array([1.0, 1.0, 1.0, 1e-24, 1e-24, 1e-24]) + pp = DataSet1D(name='pp', x=q, y=r_pp, ye=variance) + mm = DataSet1D(name='mm', x=q, y=r_mm, ye=variance) + project = self._project_with(pp, mm) + + result = project.spin_asymmetry_for_experiment_at_index(0) + + assert result['low_significance_points'] == 3 + assert result['small_denominator_points'] == 3 + assert result['masked_points'] == 6 + + +class TestSpinAsymmetryChannelValidation: + """CR2: do not advertise a spin asymmetry that cannot be computed.""" + + @staticmethod + def _project_with(pp: DataSet1D, mm: DataSet1D) -> Project: + global_object.map._clear() + project = Project() + project.calculator = 'refl1d' + project.models = ModelCollection(_magnetic_model(None)) + project._experiments[0] = PolarizedDataSet(name='validation', channels={'pp': pp, 'mm': mm}, model=project.models[0]) + return project + + def test_empty_channel_is_not_advertised(self): + q = np.linspace(0.01, 0.2, 5) + project = self._project_with( + DataSet1D(name='pp', x=np.array([]), y=np.array([])), + DataSet1D(name='mm', x=q, y=np.full_like(q, 0.2)), + ) + + assert project.experiment_supports_spin_asymmetry_at_index(0) is False + + def test_duplicate_q_is_not_advertised(self): + q = np.array([0.01, 0.02, 0.02, 0.03]) + project = self._project_with( + DataSet1D(name='pp', x=q, y=np.full_like(q, 0.6)), + DataSet1D(name='mm', x=q, y=np.full_like(q, 0.2)), + ) + + assert project.experiment_supports_spin_asymmetry_at_index(0) is False + + def test_non_finite_q_is_not_advertised(self): + q = np.array([0.01, 0.02, np.nan, 0.03]) + project = self._project_with( + DataSet1D(name='pp', x=q, y=np.full_like(q, 0.6)), + DataSet1D(name='mm', x=q, y=np.full_like(q, 0.2)), + ) + + assert project.experiment_supports_spin_asymmetry_at_index(0) is False + + def test_descending_grid_is_sorted_before_pairing(self): + # np.interp needs an increasing grid and returns nonsense otherwise. + q = np.linspace(0.01, 0.2, 6) + descending = q[::-1] + project = self._project_with( + DataSet1D(name='pp', x=descending, y=np.full_like(q, 0.6)), + DataSet1D(name='mm', x=q + 0.0005, y=np.linspace(0.2, 0.3, 6)), + ) + + assert project.experiment_supports_spin_asymmetry_at_index(0) is True + measured = project.spin_asymmetry_for_experiment_at_index(0)['measured'] + + assert np.all(np.diff(measured.x) > 0) + assert np.all(np.isfinite(measured.y)) + + +class TestSpinAsymmetryInterpolatedVariance: + """CR2: variances interpolate with squared weights.""" + + def test_midpoint_variance_uses_squared_weights(self): + global_object.map._clear() + project = Project() + project.calculator = 'refl1d' + project.models = ModelCollection(_magnetic_model(None)) + # mm sampled at 0.10 and 0.20 with variances 1 and 9; pp asks for 0.15. + pp = DataSet1D(name='pp', x=np.array([0.15]), y=np.array([1.0]), ye=np.array([0.0])) + mm = DataSet1D(name='mm', x=np.array([0.10, 0.20]), y=np.array([0.5, 0.5]), ye=np.array([0.01, 0.09])) + project._experiments[0] = PolarizedDataSet(name='interp', channels={'pp': pp, 'mm': mm}, model=project.models[0]) + + result = project.spin_asymmetry_for_experiment_at_index(0) + + # var_mm at the midpoint: 0.25*0.01 + 0.25*0.09 = 0.025 (the linear rule + # would give 0.05). + r_pp, r_mm = 1.0, 0.5 + expected = 4.0 * (r_pp**2 * 0.025) / (r_pp + r_mm) ** 4 + assert_allclose(result['measured'].ye, [expected], rtol=1e-12) + + def test_endpoints_take_the_source_variance_unchanged(self): + global_object.map._clear() + project = Project() + project.calculator = 'refl1d' + project.models = ModelCollection(_magnetic_model(None)) + pp = DataSet1D(name='pp', x=np.array([0.10, 0.20]), y=np.array([1.0, 1.0]), ye=np.array([0.0, 0.0])) + mm = DataSet1D(name='mm', x=np.array([0.10, 0.15, 0.20]), y=np.array([0.5, 0.5, 0.5]), ye=np.array([0.01, 0.04, 0.09])) + project._experiments[0] = PolarizedDataSet(name='endpoints', channels={'pp': pp, 'mm': mm}, model=project.models[0]) + + result = project.spin_asymmetry_for_experiment_at_index(0) + + r_pp, r_mm = 1.0, 0.5 + expected = 4.0 * (r_pp**2 * np.array([0.01, 0.09])) / (r_pp + r_mm) ** 4 + assert_allclose(result['measured'].ye, expected, rtol=1e-12) + + +class TestMagneticProfileDisplayContinuity: + """CR2: a periodic angle must not be drawn as a full sweep.""" + + def test_angle_is_continuous_across_the_zero_boundary(self): + profiles = TestMagneticProfileSmoothing()._profiles(359.0, 1.0) + + theta = profiles['theta_m'].y + + # 359 -> 361 rather than 359 -> 1: no 358-degree jump anywhere. + assert np.abs(np.diff(theta)).max() < 10.0 + assert theta.max() - theta.min() < 10.0 + + def test_component_smoothing_failure_is_not_papered_over(self): + # The wrapper must refuse rather than serve refl1d's angle-smoothed + # profile, which misreports the splitting at such an interface. + global_object.map._clear() + model = _magnetic_model(LayerMagnetism(rho_m=2.5, theta_m=270.0)) + project = Project() + project.calculator = 'refl1d' + project.models = ModelCollection(model) + calculator = project.models[0].interface() + + with patch.object( + type(calculator._wrapper), '_smoothed_magnetic_vector', side_effect=NotImplementedError('no microslabs') + ): + with pytest.raises(NotImplementedError): + project.magnetic_sld_data_for_model_at_index(0) + + +class TestCalculatorCapabilities: + """Helpers an application needs to offer the engine magnetism requires.""" + + def test_reports_which_calculators_support_magnetism(self): + project = Project() + + supporting = project.calculators_supporting_magnetism + + assert supporting == ['refl1d'] + # Asking must not change the active calculator. + assert project.calculator == 'refnx' + + def test_models_have_magnetism_follows_the_sample(self): + project = Project() + project.calculator = 'refl1d' + project.default_model() + + assert project.models_have_magnetism is False + + project.models[0].sample[1].layers[0].magnetism = LayerMagnetism(rho_m=2.0) + assert project.models_have_magnetism is True + + project.models[0].sample[1].layers[0].magnetism = None + assert project.models_have_magnetism is False