Skip to content

[ESSSANS] Apply the SANS beam center as a beam direction - #729

Open
SimonHeybrock wants to merge 7 commits into
mainfrom
404-beam-center-direction
Open

SimonHeybrock wants to merge 7 commits into
mainfrom
404-beam-center-direction

Conversation

@SimonHeybrock

@SimonHeybrock SimonHeybrock commented Aug 26, 2026

Copy link
Copy Markdown
Member

Fixes #404.

Why

The beam center was applied as a fixed transverse shift of every detector pixel. That is wrong on an instrument with banks at different distances, which is what #404 is about, and it also leaked into places that have nothing to do with the beam.

The information in #404 is largely superseded. The current understanding is that the rear-detector rail calibration at Loki will be embedded in the positions in the file, that the sample is where it claims to be, and that the beam center therefore describes the direction of the beam, set by the collimation slits. A given angular deviation of the beam shows up as a proportionally smaller transverse offset on a bank closer to the sample, so a single offset cannot be applied unchanged to all banks.

What changed

Four independent steps, one commit each, on top of a deps commit that requires the scippneutron release they need. Two smaller follow-up commits are described below them.

fix(sans): measure beam center relative to sample position — the center-of-mass finder projected the center-of-mass onto the plane normal to the beam without first subtracting the sample position, so the offset was measured from the origin of the lab frame rather than from the beam axis through the sample. Latent only: every currently supported instrument puts the sample on the beam axis at the origin, and the results are bit-for-bit unchanged on the Loki-at-Larmor and SANS2D test data. Fixed because the next commit makes the sample position load-bearing.

refactor(sans): apply beam center to the scattered beam, not to pixel positions — the beam center describes where the beam is, not where the detector is, so it now overrides scattered_beam in the coordinate transformation graph instead of being turned into a DetectorPositionOffset. The pixel positions keep the values from the file. Callers no longer have to undo the shift to recover the real geometry, which removes the raw_pos = position + beam_center workarounds in the SANS2D masks.

two_theta, phi, Q and the wavelength are unchanged bit for bit; I verified this by patching the pre-refactor code so that only its solid angle sees unshifted positions, which reproduces the new results exactly. The one thing that does change is the solid angle, which was computed from beam-center-shifted positions and so carried a smooth spurious tilt across every bank. Solid angle is a property of the real geometry and must not depend on the beam. On the real Loki geometry the size of that artefact is:

solid-angle-loki

Spurious change in solid angle against pixel position projected on the beam-center direction. Each bank is a straight line through zero, with a slope set by its distance from the sample.

banks distance spurious solid-angle error
rear, loki_detector_0 5.05 m -0.28% to +0.27%
mid frames, loki_detector_1-4 2.96 - 3.38 m -0.77% to +0.75%
front frames, loki_detector_5-8 1.21 - 1.63 m -3.52% to +3.63%

It is a tilt rather than scatter: each bank is a straight line through zero when plotted against the pixel position projected on the beam-center direction, with a slope set by its distance from the sample, so it biases I(Q) systematically rather than averaging out. The knock-on effect on the single-bank Loki-at-Larmor test data is only 0.041% in I(Q) and 0.22% in I(Qx,Qy), which is why the reference results move at all.

feat(sans): treat the beam center as a beam direction — every pixel is now shifted in proportion to its own distance from the sample, which maps the actual beam onto the nominal axis. This is the actual fix for #404, and it covers the depth of a bank without any special treatment.

refactor(sans): apply the beam center as an incident beam direction — the shear above is replaced by pointing incident_beam along the beam center, which is what a beam center is. Exact everywhere rather than exact along the beam and first order elsewhere, and it deletes the shear provider.

This requires scipp/scippneutron#725, which makes the beam-aligned coordinate system follow the incident beam instead of its projection onto the horizontal plane. Without it, phi and Qx/Qy silently drop the vertical component of the beam center. That is released in scippneutron 26.9.1, which the deps commit requires and pins in the lock file.

For the real Loki geometry (geometry-loki-2026-04-13.nxs, sample at the origin), taking a 34.3 mm offset determined on the rear bank:

loki-banks

Transverse shift applied to pixels against distance from the sample. Before is a flat line at 34.3 mm for all nine banks; after is a straight line through the sample position. Thick segments mark each bank's depth extent.

bank z range / m shift before / mm shift after / mm over-correction
loki_detector_0 (rear) 5.005 – 5.099 34.3 34.0 – 34.7 1.00x
loki_detector_1 2.891 – 3.029 34.3 19.7 – 20.6 1.71x
loki_detector_2 3.321 – 3.441 34.3 22.6 – 23.4 1.49x
loki_detector_3 2.891 – 3.029 34.3 19.7 – 20.6 1.71x
loki_detector_4 3.321 – 3.441 34.3 22.6 – 23.4 1.49x
loki_detector_5 1.044 – 1.370 34.3 7.1 – 9.3 4.19x
loki_detector_6 1.502 – 1.760 34.3 10.2 – 12.0 3.10x
loki_detector_7 1.112 – 1.370 34.3 7.6 – 9.3 4.07x
loki_detector_8 1.502 – 1.759 34.3 10.2 – 12.0 3.10x

The front window frames were over-corrected by more than a factor of four. Worth noting for the review: the banks are much deeper than one might assume, up to 327 mm for loki_detector_5, which is 27% of its own distance from the sample. Doing the correction per pixel rather than per bank costs nothing here and removes the question entirely.

How wrong was that? Geometry alone answers it, using the real pixel positions and comparing the scattering angle each pixel gets under the two treatments:

q-error-per-bank

Error in Q per bank from applying the rear-bank offset unchanged. Dot is the median pixel, bars are the 90th and 99th percentile.

bank z / m 2theta range median |dQ|/Q p90 p99
loki_detector_0 (rear) 5.052 0.01 - 10.9 deg 0.02% 0.06% 0.20%
loki_detector_1 2.960 5.3 - 17.1 deg 1.41% 2.58% 3.47%
loki_detector_2 3.381 3.7 - 10.6 deg 2.15% 3.40% 4.29%
loki_detector_3 2.960 4.9 - 16.8 deg 1.29% 2.68% 3.67%
loki_detector_4 3.381 3.1 - 10.0 deg 2.23% 3.80% 5.08%
loki_detector_5 1.207 15.1 - 51.4 deg 1.31% 2.99% 4.41%
loki_detector_6 1.631 10.5 - 41.9 deg 1.67% 3.49% 5.41%
loki_detector_7 1.241 14.8 - 45.2 deg 1.70% 3.32% 4.61%
loki_detector_8 1.631 9.9 - 42.0 deg 1.64% 3.61% 5.70%

A systematic, bank-wide 1.3% to 2.2% error in Q on the window frames, reaching 5.7% in the tail, against 0.02% on the bank the beam center was measured on. That is the size of the bug.

On the single-bank Loki-at-Larmor test data there is, correctly, almost nothing to see: with one bank the correction changes by at most 0.58 mm, far below the pixel size. What little change appears is binning jitter, pixels crossing Q-bin boundaries, rather than a shift of the curve. Rebinning shows this directly:

Q bins mean change rms change |mean|/rms sign flips between adjacent bins
100 +0.004 sigma 0.283 sigma 0.01 65%
25 +0.010 sigma 0.117 sigma 0.09 54%
10 +0.026 sigma 0.095 sigma 0.27 56%

A smooth systematic survives rebinning; this collapses as the bins coarsen while the mean stays at zero, and adjacent bins disagree in sign more often than chance, which is what one bin losing what its neighbour gains looks like. So for existing single-bank reductions this commit is a no-op to within the noise, and the reference results move by 0.14 sigma median and 0.63 sigma at most.

The Loki-at-Larmor reference results are regenerated in the two commits that change them.

refactor(sans): pass a beam center, not plane offsets, to the quadrant cost — the I(Q) finder parametrizes the beam center by two offsets within a plane normal to the beam, which exists only because the minimizer needs floats. That parametrization is now confined to the minimize call, so the internal _iofq_in_quadrants and _cost take a beam center directly. The user guide's walk-through of the algorithm no longer has to import a private class in order to build one.

fix(sans): let beam_center_from_iofq run without a beam center set — computing the coordinate transformation graph now requires a BeamCenter, so the I(Q) finder raised UnsatisfiedRequirement unless the caller had already set one, for a function whose job is to determine it. The center-of-mass finder already guards against this with _with_default_beam_center; the same guard now applies here. Results are unchanged for existing callers, which all set a zero beam center first.

User-facing change

BeamCenter is now the position of the beam center relative to the sample, sc.vector([x, y, L_ref], unit='m'): the transverse offset together with the distance from the sample at which it was determined. That distance is what turns an offset into a direction, so it has to be part of the value.

Only the direction enters the correction; the magnitude cancels. A value determined on one bank therefore applies to all banks, and, if the beam direction really is set by the collimation alone, also to a detector moved to a different distance along its rail — which was not true before, since the old code would have applied the wrong offset. Conversely the value is tied to the collimation that produced it, so it has to be re-determined when the slits change. Nothing in the files records which collimation a given value came from, so that one is documented rather than checked.

A zero vector still means "no correction", so existing wf[BeamCenter] = sc.vector([0, 0, 0], unit='m') keeps working unchanged. A non-zero offset with no distance is rejected with a message explaining what is missing, rather than being silently misapplied as before. Both beam-center finders report the distance they used, so values obtained from them need no change beyond being carried around whole. The workflow GUI parameter moves from Vector2dParameter to the existing Vector3dParameter.

Accuracy and known limitation

The correction is now a rotation of the incident beam, so it is exact for every pixel and there is no approximation left to quote.

Earlier revisions of this PR applied it as a shear of the scattered beam, on the estimate that the resulting error in two_theta stayed below 1e-5. That estimate was too optimistic: measured across the Loki-at-Larmor detector, with the beam center the finder returns, the relative error in two_theta has a median of 3.8e-4 and a maximum of 1.2e-3. It grows for pixels close to the sample, which the single-pixel estimate happened to miss. That is what moves the reference results in the last commit.

The distance reported by the center-of-mass finder is the intensity-weighted mean depth of the bank, not its geometric center, because layers closer to the sample see more counts. That is consistent rather than wrong: the transverse position of the beam is linear in the distance from the sample, so any intensity-weighted average of points on the beam is again a point on the beam, and the reported distance says where it ended up. The premise holds only approximately, though, and on Loki-at-Larmor the per-layer estimates disagree by more than the geometry allows, so the uncertainty on the distance is of the order of the depth of the bank itself. That is irrelevant for extrapolating to other banks, a few centimetres out of several metres, but it means the variation of the correction within one bank is not resolved better than the noise on it. This is written up in the docstring of beam_center_from_center_of_mass.

Test plan

New tests/beam_center_test.py covers how the correction is applied: zero scattering angle at the beam center and everywhere else along the beam, phi measured around the beam rather than around the nominal axis (both horizontally and vertically), correct behaviour with an off-axis sample and with gravity, and the rejection of a beam center without a distance. New tests/beam_center_finder_test.py covers what the finders return, on small synthetic detectors that run in under a second. tests/loki/workflow_test.py gains the two workflow-level guarantees that the beam center moves neither the pixel positions nor the solid angle; both fail on the previous code.

The I(Q)-based finder is only exercised by tests that are currently skipped as too slow, so I ran it by hand on the SANS2D tutorial data: it reproduces the verified Mantid result to within the 2e-3 the skipped test asserts, both with and without a beam center set beforehand. The beam-center-finder notebook needed updating, since the internal _iofq_in_quadrants now takes a beam center, and I executed all of its cells to confirm.

While doing that I found a separate defect and filed #749: at the default tolerance the I(Q) refinement stops after 14 evaluations and never moves the vertical coordinate off the center-of-mass estimate. It is present on main, is not touched here, and means the 2e-3 above is met by a search that has not converged.

Note for anyone running the tests locally: if esssans is installed non-editable, pytest will silently pick up the installed copy rather than the working tree.

@github-actions github-actions Bot added the esssans Issues for esssans. label Aug 26, 2026
@github-actions github-actions Bot changed the title Apply the SANS beam center as a beam direction [ESSSANS] Apply the SANS beam center as a beam direction Aug 26, 2026
@SimonHeybrock
SimonHeybrock force-pushed the 404-beam-center-direction branch from 4a18f07 to ca6a774 Compare August 26, 2026 08:05
The beam-center changes that follow need the beam-aligned coordinate system to
follow the incident beam rather than its projection onto the horizontal plane
(scipp/scippneutron#725), so that phi and Qx/Qy keep the vertical part of the
beam center. Released in scippneutron 26.9.1.

esssans imports scippneutron directly but so far relied on essreduce to pull it
in, so the bound goes where the import is.
The center-of-mass beam-center finder projected the center-of-mass position
onto the plane normal to the incident beam without first subtracting the
sample position. The resulting offset was therefore measured from the origin
of the lab coordinate system rather than from the beam axis through the
sample. This was invisible so far because all supported instruments place the
sample on the beam axis at the origin.
… positions

The beam center describes where the beam is, not where the detector is. Applying
it as a detector position offset made it leak into everything derived from the
pixel positions, in particular the solid angle, which is a property of the real
geometry and must not depend on the beam. Callers also had to undo the shift to
recover the real positions, see the SANS2D masks.

Overriding `scattered_beam` in the coordinate transformation graph confines the
beam center to the quantities that are actually measured relative to the beam:
two_theta, phi and the cylindrical coordinates. Those are unchanged bit for bit;
the solid angle, and hence I(Q), changes by up to 0.04% (0.2% for I(Qx,Qy)) for
the Loki-at-Larmor reference data, so the reference results are regenerated.

The beam center no longer needs to be added back to the result of the
center-of-mass finder, since the pixel positions it works on are no longer
shifted.
The beam center was applied as the same transverse shift to every pixel. On an
instrument with banks at different distances, such as Loki, that is wrong: the
beam center is set by the collimation, so it describes a beam direction, and a
given angular deviation shows up as a proportionally smaller transverse offset
on a bank closer to the sample. Applying the offset determined on the rear bank
unchanged to the window frames overcorrects them by up to a factor of five.

BeamCenter therefore becomes the position of the beam center relative to the
sample: the transverse offset together with the distance at which it was
determined. That distance is what turns an offset into a direction, so it has to
be part of the value; a beam center given as an offset alone is now rejected
rather than silently misapplied. A zero vector still means "no correction". The
finders report the distance they used, so values obtained from them need no
change beyond being carried around whole.

Every pixel is then shifted in proportion to its own distance from the sample,
which also covers the depth of a bank without any special treatment. Note that
the distance reported by the center-of-mass finder is the intensity-weighted
mean depth of the bank rather than its geometric center; see the docstring for
why that is consistent, and for the limits of resolving anything within a bank.

For the Loki-at-Larmor reference data the results move by less than 0.3 sigma of
their own statistical uncertainty in almost every bin, so the reference results
are regenerated.
The beam center is a point on the actual beam, measured from the sample, so it
defines the beam direction. Passing that direction as `incident_beam` says so
directly. It replaces the shear, which mapped the actual beam onto the nominal
axis by displacing every pixel's scattered beam in proportion to its distance
from the sample.

The shear is exact along the beam and first order elsewhere, since it is a shear
rather than a rotation. For the Loki-at-Larmor detector that costs a relative
error in two_theta with a median of 3.8e-4 and a maximum of 1.2e-3, well above
the 1e-5 the docstring claimed. Rotating the incident beam is exact everywhere,
and it puts phi, the cylindrical coordinates and two_theta on the same footing
instead of relying on the shear having mapped the transverse plane closely
enough.

The reference results move accordingly and are regenerated.

Requires the beam-aligned coordinate system to follow the incident beam rather
than its projection onto the horizontal plane; see scipp/scippneutron#725,
released in 26.9.1. Without it, phi and Qx/Qy would drop the vertical part of
the beam center.
@SimonHeybrock
SimonHeybrock force-pushed the 404-beam-center-direction branch from 1187c95 to 723daa4 Compare September 14, 2026 05:18
…t cost

The beam-center search parametrizes the beam center by two offsets within a
plane normal to the beam. That parametrization exists only because the
minimizer needs floats, so confine it to the minimizer call.
`_iofq_in_quadrants` and `_cost` now take a beam center directly.

The user guide's walk-through of the algorithm no longer needs the private
`_BeamPlane`. It builds the beam center itself, which makes the role of the
distance along the beam visible to the reader instead of hiding it in a
factory call.

`_BeamPlane.from_beam_center` takes the unit vectors and the incident beam
rather than data and a graph, so `beam_center_from_iofq` can obtain them from
the `transform_coords` call it already makes for the minimizer bounds.

The class docstring records the precondition that signature carries: the
unit vectors must belong to the same incident beam.
@SimonHeybrock
SimonHeybrock force-pushed the 404-beam-center-direction branch from ccc9460 to a4c3479 Compare September 14, 2026 07:21
Computing the coordinate transform graph requires a BeamCenter, so the finder
raised UnsatisfiedRequirement unless the caller had already set one -- for a
function whose job is to determine it. beam_center_from_center_of_mass guards
against this with _with_default_beam_center; apply the same guard here.

Masked so far because every call site sets a zero beam center first, and the
result is unchanged for those.
@nvaytet

nvaytet commented Sep 17, 2026

Copy link
Copy Markdown
Member

Can you explain with a drawing or something what you are fixing, because that giant description is difficult to parse...

@SimonHeybrock

Copy link
Copy Markdown
Member Author

Can you explain with a drawing or something what you are fixing, because that giant description is difficult to parse...

The drawing you are looking for is the second image in the PR description.

The secondary fix is that the solid angle used to be computed after a detector offset (by beam center) was applied, which is obviously wrong as the the detector is not really shifted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

esssans Issues for esssans.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SANS beam center correction may need to be applied differently

2 participants