rugnux: restrain the gauge-weak beam centre toward the header in geometry refinement

Single-axis rotation has a gauge degeneracy: rotating the whole experiment
about the spindle leaves every spot position unchanged, so the beam-centre
component parallel to the spindle is a null / under-determined direction.
Refining it freely lets it wander a few pixels and absorb centroid
systematics into a wrong beam that the co-refined orientation keeps
position-consistent.

Add a soft header prior on that one component in the shared XtalOptimizer:
residual = w*(beam[parallel] - header), with w set so the restraint behaves
like a sigma_px-pixel prior competing with the positional residuals. The
gauge direction has ~zero data sensitivity, so the prior pins it near the
LaB6-monitored header, while a real, well-supported drift can still overcome
it (the beam does drift). One restraint covers all three refinement sites at
once: the noisy accumulated-spot primary, the per-frame filter, and
post-refine.

Validated against independent XDS processing: neutral on well-determined
data (the gauge direction has no effect there), and a real low-resolution
accuracy gain where the free refinement had otherwise wandered off the
header - the merged intensities move back into agreement with XDS while the
internal CC1/2, which is blind to this, barely changes. No space-group
determinations change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 17:31:45 +02:00
co-authored by Claude Opus 4.8
parent c9ed9c3776
commit c140b3567d
@@ -10,6 +10,21 @@
#include "ceres/rotation.h"
#include "LatticeReduction.h"
// Soft header prior on ONE beam-centre component (the spindle-parallel, gauge-weak one). Residual = w*(b - b0);
// the caller sets w so the prior behaves like a sigma-pixel restraint that competes with the (unit-weight)
// positional residuals - strong enough to pin the gauge direction, negligible in the well-constrained one.
struct BeamComponentPrior {
BeamComponentPrior(int component, double b0, double weight)
: component(component), b0(b0), weight(weight) {}
template<typename T>
bool operator()(const T *const beam, T *residual) const {
residual[0] = T(weight) * (beam[component] - T(b0));
return true;
}
int component;
double b0, weight;
};
struct XtalResidualRotationOnlyPrecomp {
XtalResidualRotationOnlyPrecomp(const Coord &recip_obs,
const CrystalLattice &latt,
@@ -199,6 +214,29 @@ bool XtalOptimizerInternal(XtalOptimizerData &data,
if (!data.refine_beam_center)
problem.SetParameterBlockConstant(beam);
else if (data.axis) {
// Gauge handling (single-axis rotation): rotating the whole experiment about the spindle leaves every
// spot position unchanged, so the beam-centre component PARALLEL to the spindle is a null/gauge-weak
// direction. Refining it freely lets it wander (~+3 px) and absorb centroid systematics into a wrong
// beam that the co-refined orientation keeps position-consistent. Rather than freeze it (the beam
// does drift - it is only LaB6-monitored to ~a few px), RESTRAIN it toward the header with a soft
// prior: the gauge direction has ~zero data sensitivity so the prior pins it near the header, while a
// real, well-supported drift can still overcome it. The spindle is along a detector axis in standard
// geometry, so restrain the dominant of X / Y.
const Coord spindle = data.axis->GetAxis();
const int parallel = (std::fabs(spindle.x) >= std::fabs(spindle.y)) ? 0 : 1;
// Weight so the prior is a sigma_px-pixel restraint that competes with the positional residuals.
// k = d|recip|/d(beam_px) ~ pixel/(distance*lambda) [A^-1/px]; scaling by sqrt(#residuals) makes the
// prior's curvature ~ (1/9) of the well-constrained-data curvature at sigma_px=3, i.e. data wins the
// perpendicular direction, the prior wins the gauge one.
constexpr double sigma_px = 3.0;
const double k = data.geom.GetPixelSize_mm() / (distance_mm * data.geom.GetWavelength_A());
const double w = k * std::sqrt(static_cast<double>(problem.NumResidualBlocks())) / sigma_px;
problem.AddResidualBlock(
new ceres::AutoDiffCostFunction<BeamComponentPrior, 1, 2>(
new BeamComponentPrior(parallel, beam[parallel], w)),
nullptr, beam);
}
if (!data.refine_detector_angles) {
problem.SetParameterBlockConstant(detector_rot);