Whether a sweep determines its detector distance at all was decided by running the canonical pass twice - once at the post-refined distance, once at the header's - and keeping whichever realised the lower held-out residual, the header's on a tie. The first-stage test that sent a run down that arm compared the free and held fits on the POOLED held-out residual, and pooling is what made it uninformative: the positional family cannot see the distance along the degenerate direction, so it dilutes the one family that can, while the unpaired standard error of a heavy-tailed mean of squares is 2-25 % of the mean against in-fit differences of 0.1-1 %. The test therefore said "cannot tell" on four fifths of the fitted sweeps and the arm ran on most of a corpus, buying by prior what it could not measure. The two hypotheses are now compared on the one residual family that can tell them apart. The excitation residual never involves the detector, so it is blind to the distance itself; what it sees is the cell scale, and a held fit at a wrong header distance is forced into a wrong cell scale by the spot positions, which the rocking angles then refuse - measured on a sweep whose header was 1.4 % long, the held fit's held-out excitation residual is seventeen times the free fit's. Where freeing the distance lowers that residual by more than its own standard error the free fit is committed as before; where it does not, the held fit is committed - header distance, refined beam, cell, orientation and axis - and the walk and the commit run held. The positional residual is deliberately not consulted: its in-fit gain along the degenerate direction is the one re-integration erases. The question is asked only while the run is still at the file's distance. A run that has walked off the header has already refuted that hypothesis by re-integrating, and asking it again at every round stalls a walk short of its fixed point, because the excitation standard error at the walk's tail is outlier-dominated (measured: a walk stopped 0.4 % early, seven passes, cell 0.65 % off against 0.26 %, ISa 10.6 against 14.0). So there is no arm, and with it go the two probe passes that measured it and the canonical pass the losing arm used to cost: the decision costs two Ceres solves. On thirteen rotation sweeps covering both verdicts, every decision the arm took by evidence or by its tie rule is reproduced at the fit, except where the excitation family sees what the pooled test could not and the free distance - the better cell against an external reference - is taken instead; merged intensities are unchanged where the verdict is. POSTREFINE_DISTANCE_HELD now means "the committed fit held the header distance", and is cleared where a later geometry walk left it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
157 lines
10 KiB
C++
157 lines
10 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#pragma once
|
|
|
|
#include <array>
|
|
#include <cstdint>
|
|
#include <limits>
|
|
#include <memory>
|
|
#include <optional>
|
|
#include <string>
|
|
#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. One JOINT fit
|
|
// refines the crystal (orientation, cell, rotation axis) and the detector (distance, beam centre) together
|
|
// 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.
|
|
// Where there are more reflections than the fit's caps, the strongest by I/sigma are the ones kept;
|
|
// inside the fit every reflection carries the same weight.
|
|
|
|
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;
|
|
// The joint fit is one decision - the crystal and the detector are refined together and committed
|
|
// together - so these two are always equal. Both are kept because the report names them separately.
|
|
bool cell_refined = false; // the joint fit passed cross-validation (crystal half)
|
|
bool detector_refined = false; // the joint fit passed cross-validation (detector half)
|
|
// The fit moved the distance or a cell length by more than one step of its trust region (see
|
|
// STEP_BOUND in PostRefine.cpp), so it walked rather than settling beside the geometry it started
|
|
// from. Such a move is not refused for its size - it is RATIFIED by re-integrating and re-indexing
|
|
// at it, which a second lattice does not survive and a wrong header does (see RunAllPasses).
|
|
bool large_move = false;
|
|
// What was committed is the fit with the distance HELD at the header - everything else as free as
|
|
// ever - because freeing it did not lower the held-out excitation residual by more than that
|
|
// residual's noise (see the joint fit in PostRefine.cpp). The report names it.
|
|
bool distance_held = false;
|
|
// The held-out residual at the geometry the fit STARTED from - what the integration this fit was
|
|
// handed actually realises there, before the fit moves anything - and its standard error over the
|
|
// held-out values. Two passes at two geometries are compared on this, not on the fit's own
|
|
// prediction of where it is going (see the geometry walk in RunAllPasses). NaN where the fit had
|
|
// too few observations to measure it.
|
|
double held_out_before = std::numeric_limits<double>::quiet_NaN();
|
|
double held_out_before_se = std::numeric_limits<double>::quiet_NaN();
|
|
// The cell the fit started from: the one this pass's own indexing refined. Beside `cell` (what the
|
|
// fit committed), and against the next pass's cell_before, it says whether re-indexing at the
|
|
// committed geometry ratifies the committed cell.
|
|
UnitCell cell_before{};
|
|
// Which test refused the fit and what it wanted, where it was refused; empty where it committed.
|
|
// Nothing else says it - the geometry that comes out of a refusal is the nominal one whatever
|
|
// refused it, so a silent refusal cannot be told from a fit that never wanted to move.
|
|
std::string refused_reason;
|
|
// 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 the joint fit as a single free parameter, with the crystal 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: the crystal (from phi_obs and the observed
|
|
// spot positions) and the detector distance + beam centre, in one
|
|
// cross-validated fit. The only supported refinement mode.
|
|
double excitation_weight = 1.0; // weight of the phi/excitation residual vs the positional one
|
|
// The geometry handed in still has the file's distance, so "the header distance is right" is a
|
|
// hypothesis the fit can hold and test (PostRefineResult::distance_held). Once a run has moved off
|
|
// the header - a walk, see RunAllPasses - that hypothesis is already refuted and the distance is
|
|
// simply free.
|
|
bool distance_at_header = true;
|
|
int min_events = 50;
|
|
int num_threads = 1;
|
|
// An independent measurement of the beam centre in pixels, where the run has one (rugnux's pre-scan
|
|
// fit to the isotropy of the scattered background, which runs on every rotation run). The fit bounds
|
|
// how far it may move the beam from whichever of this and the nominal centre is NEARER; see
|
|
// BEAM_BOUND_PXL in PostRefine.cpp for why the nominal centre alone is not enough to bound it.
|
|
std::optional<std::array<double, 2>> measured_beam_px;
|
|
};
|
|
|
|
// One integrated partial, flattened across all images. Kept as narrow as the sort and the event
|
|
// split allow: on a large cell this array is gigabytes, and the scatter and every level of the
|
|
// per-bucket sort move all of it. The goniometer angle is not stored - it is a function of the
|
|
// image number alone, and is rebuilt from it where it is needed.
|
|
struct PostRefinePartial {
|
|
// Sixteen bits are plenty for a Miller index - |h| <= a / d_min, in the hundreds even on the
|
|
// longest axis at atomic resolution - and here four bytes of every partial are four bytes of an
|
|
// array that is gigabytes on a large cell.
|
|
int16_t h, k, l;
|
|
float img;
|
|
float I, sigma;
|
|
float obs_x, obs_y; // observed spot centroid (pixels); NAN if the box sum found no centroid
|
|
};
|
|
|
|
// The gathered form the fit runs on: every usable partial, bucketed by h (bstart = bucket starts,
|
|
// order = the buckets largest first) and sorted by (h, k, l, img) inside each bucket. Gathered
|
|
// apart from the fit itself so a caller that is finished with the per-image reflection vectors can
|
|
// hand them back before the fit's own allocations arrive - on a fine-sliced long axis the vectors
|
|
// are several times this array.
|
|
struct PostRefineObservations {
|
|
std::unique_ptr<PostRefinePartial[]> pts;
|
|
size_t n_pts = 0;
|
|
std::vector<int32_t> bstart; // size H+1: where each h bucket starts in pts
|
|
std::vector<int> order; // size H: bucket indices, largest bucket first
|
|
};
|
|
|
|
// Gather and sort the usable partials out of the per-image outcomes. release_reflections: each
|
|
// image's reflection vector is freed the moment it has been read - only for a caller that will
|
|
// never touch them again (the rotation geometry pre-pass, whose reflections are never written).
|
|
// An allocation failure returns an empty gather (n_pts = 0), which the fit refuses - the same
|
|
// silent refusal the fit gives every other failure.
|
|
PostRefineObservations GatherPostRefineObservations(std::vector<IntegrationOutcome> &outcomes,
|
|
size_t nthreads, bool release_reflections,
|
|
Logger &logger);
|
|
|
|
// nominal_geom / reference_latt: the current detector geometry and the phi=0 reference lattice (orientation
|
|
// + cell) from rotation indexing. obs: the gathered per-image integrated reflections (observed_x/y, I,
|
|
// sigma, image_number), from GatherPostRefineObservations. axis: the goniometer. Returns ok=false
|
|
// (geometry untouched) on failure.
|
|
PostRefineResult PostRefineRotationGeometry(PostRefineObservations obs,
|
|
const GoniometerAxis &axis,
|
|
const DiffractionGeometry &nominal_geom,
|
|
const CrystalLattice &reference_latt,
|
|
const PostRefineSettings &settings,
|
|
Logger &logger);
|
|
|
|
// Whether the held-out residual `after` realises at its geometry is below the one `before` realises at
|
|
// its own by more than the standard error of the difference - the cross-validation noise of the two
|
|
// measurements. False where either was not measured.
|
|
bool HeldOutResidualFell(const PostRefineResult &before, const PostRefineResult &after);
|
|
|
|
// Whether re-indexing at the geometry `fit` committed pushed the committed cell back: on the cell length
|
|
// the fit moved most, the cell `next` (the fit of the pass run at that geometry) started from lies on the
|
|
// side the fit moved away from. Returns +-(axis + 1), signed as the fit's move, where it did; 0 where the
|
|
// re-indexing followed the fit, or the fit committed nothing.
|
|
int ReindexPushesCellBack(const PostRefineResult &fit, const PostRefineResult &next);
|