Files
Jungfraujoch/image_analysis/scale_merge/ResolutionCutoff.h
T
leonarski_fandClaude Fable 5.1 61afff891b Rotation scaling: the per-frame scale loop runs to convergence, gauge pinned, plain fit
The alternating per-frame scaling used to run a fixed three rounds of a Cauchy-reweighted fit
against a reference that included half a sweep's rocking-curve tails. Three rounds left a short
sweep merged in P1 far from its answer (a 90 deg tetragonal sweep refused its 422 with the P1
scales anti-correlated with the converged ones), and more rounds did not help: the fit had no
fixed point. Two things made it walk. The objective is invariant under G -> cG with the reference
-> reference/c, so every round moved every scale by a constant factor; and the robust loss, iterated
against a reference refitted each round, drops the strong reflections of a frame whose scale is off
by a third (ten-sigma residuals) and lets the weak ones carry it further off - measured on a 360 deg
sweep the scales shrank 10-30% per round for thirty rounds and the H ratio of a genuine 222 read 21x.

Now the loop pins its gauge every iteration (G divided by the precision-weighted typical frame
scale G_ref = sum G^3 / sum G^2, one definition shared with the CC1/2 weight), fits the plain
weighted least-squares slope with the weights the reference uses and only on the observations the
reference is built from (the partiality floor), and stops when the rms |log(G_new/G_old)| over the
frames falls below 1e-3. With the same weights on both sides the alternating fit is exact
coordinate descent on one objective and cannot climb; measured, the 360 deg sweep settles in 19
rounds and the 90 deg one in 30-50, each pass's partials and fulls loops alike.

--scaling-iterations is now the cap (default 100). A loop that reaches it is logged, the report
prints SCALING_ITERATIONS and raises SCALING_NOT_CONVERGED, and the correction surfaces run to the
same tolerance under their own cap. On the GPU the loop runs one iteration per call so the pin and
the step test read the same numbers as on the host; the fulls' reset is split out of ScaleFulls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
2026-09-20 18:45:17 +02:00

69 lines
4.6 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <optional>
#include <string>
#include <vector>
#include "../../common/Logger.h"
#include "../../common/Reflection.h" // MergedReflection
#include "../../common/ScalingSettings.h" // ResolutionCutoffMethod
// Automatic high-resolution cutoff from the CC1/2 fall-off of the merged half-sets (DIALS-style).
// The merge itself, the error model and the per-image _process.h5 are left untouched - only the
// written reflections and the reported shell table should be trimmed to the returned d_cut.
//
// Method (see docs/rugnux_resolution_cutoff_design.md): bin CC1/2 against s = 1/d^2 in fine bins,
// fit a logistic CC1/2(s) = 1/(1+exp(k*(s-s0))) to the contiguous-from-low-res fall-off, take the s
// where the fit crosses cc_target, then extend by one mean (10-shell) shell width in s ("one shell
// too far", generous). Where the fitted crossing lands past the bins the fit was made over - a
// fall-off region too ragged for a logistic to follow, so the crossing is an extrapolation rather
// than something the bins show - the crossing is read off the bins themselves instead, and
// everything after it is unchanged. d_cut is nullopt when the fit is degenerate (too few bins, flat)
// or CC1/2 never falls below cc_target inside the measured range - the caller then keeps the full
// range. cc_target in (0,1); merged must carry finite I_half[0]/I_half[1] to contribute.
struct ResolutionCutoffResult {
std::optional<double> d_cut; // high-resolution limit (A); nullopt => keep the full range
// Where CC1/2 crosses cc_target, BEFORE the deliberate one-shell extension - i.e. the
// resolution the data are judged to reach, as opposed to the (coarser in s, finer in d) limit the
// reflections are actually written to. This is the number to quote. Set whenever the fit produced
// a crossing inside the measured range, even when no cut was applied.
std::optional<double> d_fit;
std::string note; // human-readable description of the decision (for logging)
};
// The run's typical per-frame scale, G_ref = sum G^3 / sum G^2 over the observations (n_obs[f] of
// them on frame f): precision-weighted, so frames the crystal barely diffracted on cannot drag it
// down however many of them there are, where a median of the frames is itself a dead frame on a
// sweep that spent most of its turn out of the beam. The one definition of "typical frame" - the
// scaling loop pins its gauge to it, the CC1/2 weight and every frame guard measure against it. A
// frame without a finite positive scale counts as G = 1; 1 when no frame carries an observation.
double TypicalFrameScale(const std::vector<double> &frame_scale, const std::vector<int64_t> &n_obs);
// The per-frame factor of the CC1/2 weight (MergedReflection::cc_weight): max(1, (G_ref/G)^2), the
// variance an observation from a frame at scale G carries over the same observation at the typical
// scale G_ref (TypicalFrameScale).
std::vector<double> CCHalfFrameFactors(const std::vector<double> &frame_scale,
const std::vector<int64_t> &n_obs);
ResolutionCutoffResult ComputeCCHalfLogisticCutoff(const std::vector<MergedReflection> &merged,
double cc_target, Logger &logger);
// Resolve the effective high-resolution limit and trim `merged` to it, in one place shared by the
// stills merge, the rotation merge and the offline --scale path. A manual limit (manual_limit) wins;
// otherwise, unless this is a P1 space-group search merge (for_search), the CCHalfLogistic method
// takes the auto CC1/2 cutoff. Reflections beyond the resolved limit are erased from `merged`.
// Returns the applied limit (nullopt => the full range was kept). The decision is logged as before.
// fit_limit_out, when given, receives ResolutionCutoffResult::d_fit - the CC1/2 crossing without the
// one-shell extension, for reporting. Left untouched when no automatic fit ran (a manual limit, a
// search merge, or the method turned off), so a caller can tell "not fitted" from "fitted".
std::optional<double> ApplyResolutionCutoff(std::vector<MergedReflection> &merged,
std::optional<double> manual_limit,
ResolutionCutoffMethod method,
double cc_target,
bool for_search,
Logger &logger,
std::optional<double> *fit_limit_out = nullptr);