scaling: smooth per-frame scale G across frames before the rot3d combine

ScaleOnTheFly fits each frame's scale G independently with no neighbour
coupling, so the few partials of one rocking event are weight-summed in
combine3D on inconsistent scales - jitter that never enters the full's
counting sigma and instead surfaces as scatter between symmetry mates,
inflating the error-model b (low ISa). A centered moving average of
log(G) over a small frame window (default 9, on for rot3d) removes it,
mirroring XDS's smooth scaling. Complementary to --scale-fulls (which
rescales between fulls, after the combine): smoothing fixes within-event
scale, scale-fulls fixes between-full.

On the rotation lysozyme set (1.4A, merged, with --scale-fulls): ISa
11.7 -> 15.0, R_meas 10.0% -> 8.3%, CCref stable, chi2 ~0.97 (honest).
Anomalous (full-res): ANODE S-peak 0.61x -> 0.80x of XDS.

--smooth-g[=window] tunes/disables it (=0 off); --mosaicity <deg> is a
diagnostic that fixes the scaling mosaicity for sweeps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 19:02:47 +02:00
co-authored by Claude Opus 4.8
parent 02477f1ce4
commit 99b923e727
5 changed files with 96 additions and 1 deletions
+43
View File
@@ -78,6 +78,44 @@ namespace {
}
logger.Info("Scaled fulls (XDS order, Unity model)");
}
// Smooth the per-frame scale G across frames before the rot3d combine. ScaleOnTheFly fits each
// frame's G independently, so the few partials of one rocking event get inconsistent scales when
// weight-summed; a centered moving average of log(G) over a small odd frame window removes that
// jitter. Only G changed, so each reflection's image_scale_corr (proportional to 1/G) is rescaled
// by g_old/g_smooth. Frames without a fitted G (n<MIN_REFLECTIONS) are skipped on both sides.
void SmoothImageScaleG(std::vector<IntegrationOutcome> &outcomes, int window, Logger &logger) {
const int n = static_cast<int>(outcomes.size());
const int half = window / 2;
std::vector<double> g_smooth(n, NAN);
for (int o = 0; o < n; o++) {
double sum_log = 0.0;
int count = 0;
for (int j = std::max(0, o - half); j <= std::min(n - 1, o + half); j++) {
const auto &g = outcomes[j].image_scale_g;
if (g && std::isfinite(*g) && *g > 0.0) {
sum_log += std::log(*g);
count++;
}
}
if (count > 0)
g_smooth[o] = std::exp(sum_log / count);
}
size_t n_smoothed = 0;
for (int o = 0; o < n; o++) {
const auto &g_old = outcomes[o].image_scale_g;
if (!g_old || !std::isfinite(*g_old) || *g_old <= 0.0 || !std::isfinite(g_smooth[o]))
continue;
const double factor = *g_old / g_smooth[o];
for (auto &r : outcomes[o].reflections)
if (std::isfinite(r.image_scale_corr))
r.image_scale_corr = static_cast<float>(r.image_scale_corr * factor);
outcomes[o].image_scale_g = g_smooth[o];
n_smoothed++;
}
logger.Info("Smoothed per-frame scale G over a {}-frame window ({} of {} frames)",
window, n_smoothed, n);
}
}
JFJochProcess::JFJochProcess(JFJochHDF5Reader &reader, DiffractionExperiment experiment,
@@ -436,6 +474,11 @@ ProcessResult JFJochProcess::Run(JFJochProcessObserver *observer) {
// merging, so the error model sees counting statistics (high ISa) instead of
// rocking-curve slicing scatter.
const bool rot3d = experiment_.GetScalingSettings().GetCombine3D();
if (rot3d && experiment_.GetScalingSettings().GetSmoothGWindow() > 0) {
phase("Smoothing per-frame scale G");
SmoothImageScaleG(indexer->GetIntegrationOutcome(),
experiment_.GetScalingSettings().GetSmoothGWindow(), logger);
}
std::vector<IntegrationOutcome> combined;
if (rot3d) {
phase("Combining 3D partials");