rugnux: try the beam centre as an indexing hypothesis after a failed first pass

A beam-centre error is not repairable downstream. It is fixed in the lab frame, so accumulating a
sweep smears every reciprocal-lattice point around a circle and the FFT amplitude at an axis of
length a is multiplied by J0(2 pi delta p a/(D lambda)); past the first zero the true axis is gone
and its harmonic wins. But it is decidable from the data, on exactly the count the scheme choice
and the axis-sign rescue already use: the right centre indexes and the wrong one does not.

So after a first pass that indexes fewer than half the validation frames, step the centre a pixel
at a time and keep the first rung that clears the same majority the other guards test. It runs only
after a pass that has already failed, so a run that works never pays for it, and spot finding is not
repeated - the cache is keyed by image and the spot positions do not depend on the centre. On the
prototype this rescued 2 of 12 failing non-SLS datasets and declined cleanly on the other 10.

BOTH detector directions are searched, and that is the part to keep. Measured by injection on two
crystals: across the spindle the cell stays right and the indexed fraction collapses, 99 % to 25 %,
so that direction announces itself; along the spindle - the one the J0 derivation calls free,
because the FFT amplitude is translation-invariant - the run holds 96-100 % indexed and quietly
adopts a 2x, 3x or sqrt(3) supercell. Searching only the perpendicular direction would search the
failure that already shows.

The step is one pixel, flat. Deriving it from the J0 law was tried and is wrong: the only cell
available at that point is the one the FAILED pass returned, and on a dataset whose failed cell was
a small spurious sub-cell the formula asked for a 6 px step, which steps over the lobe it is looking
for. Taking any improvement rather than requiring a majority was also tried and is wrong: where no
centre works, the ladder wandered to the far end of its range on 10/60 against 5/60.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3yNBXk4wKdMZy1ak2NY7f
This commit is contained in:
2026-08-30 08:37:55 +02:00
co-authored by Claude Opus 5
parent 4d2bb98a13
commit a2f451cf12
3 changed files with 89 additions and 0 deletions
+58
View File
@@ -2113,6 +2113,64 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
}
}
// Beam centre as an INDEXING HYPOTHESIS. A beam-centre error is not repairable downstream: it
// is fixed in the LAB frame, so accumulating a sweep smears every reciprocal-lattice point
// around a circle and the FFT amplitude at an axis of length a is multiplied by
// J0(2 pi delta p a/(D lambda)). Past the first zero the true axis is gone and its harmonic
// wins, which is what a first-pass axis doubling usually is. It is decidable from the data on
// exactly the count the scheme choice and the axis-sign rescue already use: the right centre
// indexes and the wrong one does not.
//
// BOTH detector directions are searched, and that is the one thing to keep. The J0 law is
// about the FFT AMPLITUDE, which is translation-invariant, so a centre error along the spindle
// is free for the transform - but the step after it, fitting a lattice whose origin is the
// beam, is not, and a rigidly shifted lattice is fitted by a finer one. Measured by injection
// on two crystals: across the spindle the cell stays right and the indexed fraction collapses
// (99 % to 25 %), while along it the run keeps 96-100 % indexed and quietly adopts a 2x, 3x or
// sqrt(3) supercell. The direction that announces itself is not the dangerous one.
//
// Only after a poor pass, so a correct header costs nothing; and spot finding is not repeated,
// the cache being keyed by image and the spot POSITIONS not depending on the centre.
if (!cancelled_ && !beam_center_searched_ && config_.beam_center_search_pxl > 0
&& best.score < 0.5 * static_cast<double>(validation.size())) {
beam_center_searched_ = true;
const float beam_x = experiment_.GetBeamX_pxl(), beam_y = experiment_.GetBeamY_pxl();
const int reach = config_.beam_center_search_pxl;
logger.Info("Beam-centre hypothesis: {}/{} validation frames at ({:.2f},{:.2f}); trying "
"the centre +-{} px along each detector axis, a pixel at a time",
best.score, static_cast<int>(validation.size()), beam_x, beam_y, reach);
// Adopt only a hypothesis that INDEXES - the same majority the guards above test - rather
// than whichever rung scores best. Taking any improvement was tried and is wrong: on a
// dataset where no centre works the ladder wandered to the far end of its range on 10/60
// against 5/60 and reported a 30 px move, which is the count's noise floor.
const int adopt_bar = static_cast<int>(validation.size()) / 2;
bool adopted = false;
float won_x = beam_x, won_y = beam_y;
for (int k = 1; k <= reach && !cancelled_ && !adopted; k++) {
for (const auto &[dx, dy]: {std::pair<int, int>{-k, 0}, {k, 0}, {0, -k}, {0, k}}) {
const float trial_x = beam_x + static_cast<float>(dx);
const float trial_y = beam_y + static_cast<float>(dy);
experiment_.BeamX_pxl(trial_x).BeamY_pxl(trial_y);
const FirstPass alt = pick_best(*indexer_pool, *indexer);
logger.Info("Beam-centre hypothesis ({:.2f},{:.2f}): {}/{} frames", trial_x,
trial_y, alt.score, static_cast<int>(validation.size()));
if (alt.result.has_value() && alt.score > adopt_bar && alt.score > best.score) {
best = alt;
won_x = trial_x;
won_y = trial_y;
adopted = true;
break;
}
}
}
experiment_.BeamX_pxl(won_x).BeamY_pxl(won_y);
if (adopted)
logger.Info("Beam centre from indexing: ({:.2f},{:.2f}) -> ({:.2f},{:.2f}), moved "
"{:.2f} px, {}/{} validation frames", beam_x, beam_y, won_x, won_y,
std::hypot(won_x - beam_x, won_y - beam_y), best.score,
static_cast<int>(validation.size()));
}
// Long-axis rescue. When the de-novo cell indexes few validation frames, a long, finely-spaced
// axis was likely lost: the unconstrained FFT either collapsed it to a short sub-multiple or let
// a denser supercell over-fit the accumulated cloud (a small global-orientation error throws the
+11
View File
@@ -101,6 +101,12 @@ struct ProcessConfig {
bool estimate_beam_center = false;
bool fit_spindle = false;
// Beam centre as an INDEXING HYPOTHESIS (--beam-center-search). Runs only after a first pass that
// indexes under half the validation frames, so it costs nothing on a run that works, and it is a
// repair path rather than a measurement: it steps the centre a pixel at a time and keeps the first
// rung that indexes a majority. The value is how many pixels it reaches in each direction; 0 is off.
int beam_center_search_pxl = 12;
// Adaptive integration radius (--adaptive-integration-radius). When set, the pre-scan measures how
// wide the recorded spots are - r80, the radius holding 80 % of a spot's flux, over a fixed
// aperture that owes nothing to the integrator (SpotWidth.h) - and sets r1 from it. r1 is the
@@ -319,6 +325,11 @@ class Rugnux {
// a stills bundle adjustment, or an earlier pre-scan of this same run. The pre-scan estimate is
// a starting point for indexing, so it must not overwrite either of those.
bool beam_center_placed_ = false;
// Whether the beam-centre hypothesis ladder has already run in this RUN, not in this pass: the
// second pass starts from the geometry the first one post-refined, so searching again there
// re-asks a question already answered, and where the answer was "no centre works" it doubles the
// cost of the failure.
bool beam_center_searched_ = false;
// The recorded spot width the pre-scan measured (config_.adaptive_integration_radius), kept so the
// two-pass rotation run measures it once and both passes integrate at the same radius.
+20
View File
@@ -105,6 +105,7 @@ void print_usage() {
std::cout << " Detector mask" << std::endl;
std::cout << " --detect-beam-stop[=N|off] Find the beam stop and its holder in a projection of N images and add them to the pixel mask (bit 9), so nothing shadowed by them is integrated. ON by default (60 images); =off disables. Reflections behind the stop are attenuated but not flagged, so they are integrated low with a plausible sigma and no existing rejection catches them" << std::endl;
std::cout << " --estimate-beam-center Place the beam centre before anything is indexed, and use it in place of the header value when it is measured precisely enough. On a sweep that reaches half a turn it comes from the symmetry of the spot positions - the frames 180 deg apart are each other's mirror image, and every reflection is recorded twice - and where the sweep does not reach that far, from the isotropy of the scattered background, which needs only a few frames. It reads frames of its own, chosen as pairs half a turn apart, so it does not change the mask --detect-beam-stop finds. Ignored when --beam-x/--beam-y are given, and when a stills geometry refinement has already placed the centre from indexed spots" << std::endl;
std::cout << " --beam-center-search[=N|off] After a first pass that indexes fewer than half the validation frames, try the beam centre a pixel at a time out to N px along each detector axis and keep the first one that indexes a majority. A beam-centre error is fixed in the lab frame, so it smears the accumulated reciprocal-space cloud and the FFT takes an axis harmonic instead of the true axis; nothing downstream repairs that. ON by default (12 px); =off disables. It runs only after a pass that has already failed, so a run that indexes never pays for it" << std::endl;
std::cout << " --no-fit-spindle Take the goniometer axis from the file rather than measuring the spindle's rotation about the beam from the spots. Measuring it is the DEFAULT and the file is never right: every master writes an exact lab axis and no goniometer is one. Both mirror lines of the beam-centre estimator turn with the spindle, so an axis a few tenths of a milliradian out smears the vote until a neighbouring tooth wins. Only has an effect with --estimate-beam-center" << std::endl;
std::cout << std::endl;
@@ -235,6 +236,7 @@ enum {
OPT_BACKGROUND_RADIAL,
OPT_REFINE_GEOMETRY,
OPT_DETECT_BEAM_STOP,
OPT_BEAM_CENTER_SEARCH,
OPT_ESTIMATE_BEAM_CENTER,
OPT_FIT_SPINDLE,
OPT_NO_FIT_SPINDLE,
@@ -351,6 +353,7 @@ static option long_options[] = {
{"rotation-scale", required_argument, nullptr, OPT_ROTATION_SCALE},
{"refine-geometry", optional_argument, nullptr, OPT_REFINE_GEOMETRY},
{"detect-beam-stop", optional_argument, nullptr, OPT_DETECT_BEAM_STOP},
{"beam-center-search", optional_argument, nullptr, OPT_BEAM_CENTER_SEARCH},
{"estimate-beam-center", no_argument, nullptr, OPT_ESTIMATE_BEAM_CENTER},
{"fit-spindle", no_argument, nullptr, OPT_FIT_SPINDLE},
{"no-fit-spindle", no_argument, nullptr, OPT_NO_FIT_SPINDLE},
@@ -712,6 +715,7 @@ static int RunRugnux(int argc, char **argv) {
std::optional<bool> background_radial_arg; // when given: set = force on/off, unset = auto
std::optional<int> detect_beam_stop = 60; // --detect-beam-stop[=N|off]; on by default
bool estimate_beam_center = false; // --estimate-beam-center
int beam_center_search = 12; // --beam-center-search[=N|off]; on by default
bool fit_spindle = true; // --fit-spindle / --no-fit-spindle
std::optional<int> refine_geometry; // --refine-geometry[=N]: stills global geometry-refinement pass
bool refine_geometry_disabled = false; // --refine-geometry=off: opt out of the stills default-on
@@ -814,6 +818,21 @@ static int RunRugnux(int argc, char **argv) {
? parse_number_arg<int>(optarg, "--detect-beam-stop", logger, 1, 1000000)
: 60;
break;
case OPT_BEAM_CENTER_SEARCH:
// How far the ladder reaches, in pixels, in each direction. The step is one pixel, flat:
// a header beam centre is written in pixels and is wrong by pixels. Deriving the step from
// the J0 law was tried and is wrong, because the only cell available at that point is the
// one the FAILED pass returned - on a dataset whose failed cell was a small spurious
// sub-cell the formula asked for a 6 px step, which steps clean over the lobe it is
// looking for.
if (optarg && std::string(optarg) == "off") {
beam_center_search = 0;
break;
}
beam_center_search = optarg
? parse_number_arg<int>(optarg, "--beam-center-search", logger, 1, 1000)
: 12;
break;
case OPT_ESTIMATE_BEAM_CENTER:
estimate_beam_center = true;
break;
@@ -2371,6 +2390,7 @@ static int RunRugnux(int argc, char **argv) {
config.two_pass_rotation = two_pass_rotation;
config.detect_beam_stop = detect_beam_stop;
config.estimate_beam_center = estimate_beam_center;
config.beam_center_search_pxl = beam_center_search;
config.fit_spindle = fit_spindle;
config.adaptive_integration_radius = adaptive_integration_radius;
config.rotation_postrefine_geometry = rotation_postrefine_geometry;