Files
Jungfraujoch/image_analysis/geom_refinement/PostRefine.h
T
leonarski_fandClaude Opus 5 677ece7b59 Post-refinement: correct a goniometer that turned further than it was told
The angles a rotation dataset stores are the COMMANDED ones, so a stage whose travel is
miscalibrated leaves no trace in the header - every angle is self-consistently wrong. No
existing parameter can absorb it either: the cell scale, the axis direction, the detector
distance and the beam centre are all orthogonal to an error in rotation MAGNITUDE.

So fit it as what it is - one scalar k, the ratio of the travel to the commanded angle -
on the rocking events the geometry post-refinement already builds, after step A so the
cell scale and the axis direction are fixed and k is the only free quantity. Two details
decide whether the number means anything. The angle enters measured from the CENTRE of
the sweep: the reference orientation was fitted against the commanded angles and has
already absorbed their mean error, so measured from the goniometer's zero instead a
constant missetting about the spindle leaks into k with a gain of <phi>/<phi^2>, which
depends only on where the sweep happens to sit - on a short sweep starting near zero a
0.14 deg missetting fakes 1.4 % of k. Referred to the sweep centre that leak is
identically zero at any width. And the robust loss is scaled to the scatter the events
actually have, which varies by more than a decade between datasets, so any fixed constant
is either inert or throws away real data.

A stage fault is rare and a 1 % angle correction applied to a healthy dataset would damage
it silently, so the correction is committed only when every test passes: at least 30 deg
of sweep and 5000 events, |k-1| over 0.5 %, a misorientation of at least 0.5 deg at each
end of the sweep, and the same k from every fifth of the sweep left out. The last test is
not optional. A second lattice that dominates ONE END of a sweep - exactly what happens
where the primary stops indexing - fakes a k that passes the other two, and the hkl-hash
split used elsewhere in this file cannot see it, because both of its folds sit at the same
angles and anything structured in phi survives in both.

When it commits, the second pass re-integrates against the corrected angles. The pre-pass
mosaicity is dropped with it: that is a width in degrees fitted against angles the second
pass has just stopped using, and since the override can only ever raise the second pass's
own estimate, carrying it over would hold the second pass at the rocking width the
uncorrected angles produced - the correction half-applied.

--rotation-scale asserts a known stage calibration by hand and overrides the fit.

On the 38-crystal rotation battery the gate fires on exactly one dataset, at k = 1.01318
with 0.74 of that k surviving every fifth left out. The largest of the other 37 is
1.00211, which fails the end-error test; 34 of them sit below 1.0006. On the one that
fires:

  R_meas          39.2 -> 23.9 %   (XDS 37.1)
  CC1/2           86.5 -> 96.0 %   (XDS 94.3)
  CC1/2 outer      1.4 -> 53.4 %   (XDS 42.5)
  unique refl    40990 -> 41540    (XDS 41322)
  observations   74975 -> 103858   (XDS 129322)
  mosaicity      0.181 -> 0.159 deg

which takes it from losing to XDS on R_meas, CC1/2 and outer-shell CC1/2 to beating it on
all three, and the mosaicity drop is the inflation the uncorrected angles were producing.
Its low-resolution R_meas is the one number that moves the wrong way, 12.0 -> 13.9 %,
still well inside XDS's 18.3. No space group moves anywhere, and every other crystal's
merge is unchanged beyond the two-pass loop's own jitter - measured here as the spread of
the post-refined distance across arms that do not touch post-refinement at all, which is
larger than anything this commit produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:42:25 +02:00

66 lines
4.0 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <vector>
#include "../../common/DiffractionGeometry.h"
#include "../../common/CrystalLattice.h"
#include "../../common/GoniometerAxis.h"
#include "../../common/UnitCell.h"
#include "../../common/Logger.h"
#include "../IntegrationOutcome.h"
#include "gemmi/symmetry.hpp"
// Post-integration geometry refinement for rotation data. Unlike the at-indexing XtalOptimizer, this runs
// AFTER integration/merge, where each reflection has an OBSERVED rocking centroid phi_obs (the intensity-
// weighted mean goniometer angle over the frames it spans) and an observed spot position. It refines one
// shared crystal orientation + cell (+ optionally the detector distance) against two residuals:
// * an Ewald excitation residual evaluated at phi_obs (distance-independent) -> pins the absolute cell
// scale that the positional residual leaves degenerate with the distance. Because phi_obs is the real
// rocking angle (not a frame centre) it is unbiased.
// * the positional detector<->reciprocal residual at each partial's observed spot -> pins the distance.
// Reflections are weighted by their merged I/sigma (strong, well-measured reflections dominate).
struct PostRefineResult {
bool ok = false;
DiffractionGeometry geom; // refined (distance / beam left as configured)
UnitCell cell{}; // refined unit cell
int events_used = 0;
int obs_used = 0;
double distance_before_mm = 0.0, distance_after_mm = 0.0;
double beam_x_before_px = 0.0, beam_x_after_px = 0.0; // refined beam centre (GEOM mode)
double beam_y_before_px = 0.0, beam_y_after_px = 0.0;
bool cell_refined = false; // GEOM step A (cell scale + axis) passed cross-validation
bool detector_refined = false; // GEOM step B (distance + beam) passed cross-validation
// GONIOMETER ROTATION SCALE: the factor by which the stage actually turned relative to the angle
// stored in the file (which is the COMMANDED value, hence a stage calibration error is invisible in
// the header). Fitted after step A as a single free parameter, with the cell scale and the axis
// direction held at their committed values. Always the fitted value; 1.0 = header and stage agree.
double rotation_scale = 1.0;
// Whether the fit passed every test needed to ACT on it: enough sweep and events, a significant and
// physically relevant size, and the same k from every fifth of the sweep. Only then is it applied.
bool rotation_scale_suspect = false;
};
struct PostRefineSettings {
gemmi::CrystalSystem crystal_system = gemmi::CrystalSystem::Triclinic;
bool refine_geometry = false; // XtalOptimizer-equivalent: cell scale + axis (from phi_obs) and detector
// distance + beam centre (from the observed spot positions X,Y), as two
// separate cross-validated steps. The only supported refinement mode.
double excitation_weight = 1.0; // weight of the phi/excitation residual vs the positional one
int min_events = 50;
int num_threads = 1;
};
// nominal_geom / reference_latt: the current detector geometry and the phi=0 reference lattice (orientation
// + cell) from rotation indexing. outcomes: the per-image integrated reflections (observed_x/y, I, sigma,
// image_number). axis: the goniometer. Returns ok=false (geometry untouched) on failure.
PostRefineResult PostRefineRotationGeometry(const std::vector<IntegrationOutcome> &outcomes,
const GoniometerAxis &axis,
const DiffractionGeometry &nominal_geom,
const CrystalLattice &reference_latt,
const PostRefineSettings &settings,
Logger &logger);