Fit the modulation surface on a grid that spans the detector

The detector-frame modulation correction takes its 16x16 grid extent from a pass
over every full, but the surface is fitted only on the fulls that belong to an ASU
group. Those are two different populations, and the gap between them is whatever
was integrated past the resolution the merge uses.

That made the correction's fate depend on how far integration reached. Cut it back
and the grid contracts onto the merged disc while the cell count stays the same, so
each cell holds too few reflections, the surface over-fits, and cross-validation
throws it away - correctly, on a surface that should never have been fitted at that
scale. Varying only the integration limit on one rotation dataset, merged R_meas
came out 28.4 / 33.1 / 29.0 / 32.8 / 31.7 %, and the four-point spread is entirely
the correction switching on and off: every low value is a run where it was applied,
every high value one where it was refused, with no exceptions. Nothing else moved.

The grid now spans the detector. Cells with no observations in them keep a factor of
1 and cost nothing, and with integration running to the detector corner - the default
- the grid is the one it always was, so the common case is unchanged. On a crystal
carrying no resolution limit at all it takes merged R_meas from 39.7 % to 35.5 %.

This is a correctness fix in its own right. It also has to come first: without it, any
change that narrows the integrated resolution range trips the same over-fit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jungfrau
2026-08-17 18:04:23 -04:00
co-authored by Claude Opus 5
parent 088ba1cff8
commit 20f1827550
2 changed files with 15 additions and 10 deletions
+1
View File
@@ -1,6 +1,7 @@
# Changelog
## 1.0.0
### Unreleased
* rugnux: the detector-frame modulation correction is fitted on a grid spanning the detector rather than the reflections that happen to be present, so whether it is applied no longer depends on how far integration reached. On a crystal where it was being refused, merged R_meas improves by 4 percentage points.
* rugnux: the beam-stop pre-scan reads and accumulates its frames on several threads instead of one. On a 16M-pixel rotation dataset it drops from 7.9 s to 4.8 s; the shadow it finds is unchanged.
### 1.0.0-rc.161
@@ -1820,26 +1820,30 @@ void RotationScaleMerge::RefineModulation(int n_iter, int n_groups) {
// the detector + beam (not the rotation), the same correction concept transfers to stills.
if (fulls.empty())
return;
float pxmin = std::numeric_limits<float>::infinity(), pxmax = -pxmin, pymin = pxmin, pymax = -pxmin;
for (const Obs &o : fulls) {
if (!std::isfinite(o.px) || !std::isfinite(o.py)) continue;
pxmin = std::min(pxmin, o.px); pxmax = std::max(pxmax, o.px);
pymin = std::min(pymin, o.py); pymax = std::max(pymax, o.py);
}
if (!(pxmax > pxmin) || !(pymax > pymin))
// The grid spans the DETECTOR, not the reflections that happen to be present. Taking its extent from
// the fulls made the cell size depend on how far INTEGRATION reached - reflections this surface never
// fits, since it is fitted on the merged (in-resolution-range) ones alone. On a crystal integrated to
// the detector corner but merged well short of it that is the difference between a grid the in-range
// reflections over-determine and one they over-fit, and the cross-validation gate then flips between
// engaging the surface and refusing it: 28.4% merged R_meas with it against 32.8% without, moving
// non-monotonically as the integration limit was varied. Cells outside the data cost nothing - with no
// observations in them their factor stays at 1.
const float pxmax = static_cast<float>(x.GetXPixelsNumConv());
const float pymax = static_cast<float>(x.GetYPixelsNumConv());
if (!(pxmax > 0.0f) || !(pymax > 0.0f))
return;
// 24 rather than 16: held out on unseen frames, 16 -> 24 is worth 0.1-0.2 pp of merged R_meas,
// while 24 -> 48 buys 0.02 and 64 is worse. The cross-validation gate below refuses the surface
// outright where 24 is too fine for the data, so the finer grid needs no separate guard.
constexpr int NB = 24;
const float sx = NB / (pxmax - pxmin), sy = NB / (pymax - pymin);
const float sx = NB / pxmax, sy = NB / pymax;
std::vector<int32_t> cell(fulls.size(), -1);
ParallelChunks(static_cast<int>(fulls.size()), ThreadsForWork(fulls.size(), nthreads), [&](int lo, int hi) {
for (int i = lo; i < hi; ++i) {
const Obs &o = fulls[i];
if (!std::isfinite(o.px) || !std::isfinite(o.py)) continue;
const int ix = std::clamp(static_cast<int>((o.px - pxmin) * sx), 0, NB - 1);
const int iy = std::clamp(static_cast<int>((o.py - pymin) * sy), 0, NB - 1);
const int ix = std::clamp(static_cast<int>(o.px * sx), 0, NB - 1);
const int iy = std::clamp(static_cast<int>(o.py * sy), 0, NB - 1);
cell[i] = ix * NB + iy;
}
});