Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions .github/workflows/pypi-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]"
Expand Down
11 changes: 0 additions & 11 deletions .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/"

Expand Down
173 changes: 173 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<experiment> (<channel>)`. 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).
Expand Down
29 changes: 29 additions & 0 deletions docs/docs/api-reference/calculators.md
Original file line number Diff line number Diff line change
@@ -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
8 changes: 7 additions & 1 deletion docs/docs/tutorials/advancedfitting/bayesian_bumps.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading