Rotation scaling: skip the fulls-alone merge when the partials' merge decides
The first non-search merge on an ingest was made both ways (partials scaled, and the scale from the fulls alone) and PartialScalingWins picked one. When the prior is to scale the partials and that merge resolves its error model, the decision is the prior whatever the other arm says, so the fulls-alone merge was a whole final merge thrown away - 16% of all scaling time over the battery (238 of 258 decisions), e.g. ~1.5 s of a 22 s GPU run on an in-house cytochrome C set, 8-13 s on the largest open-arm sets. That arm is now made only when the partials' merge fails to resolve its error model (then the old sequence runs unchanged) or when the prior is the fulls (small molecules; unchanged). The partials' arm starts from g_partial = 1, the only state the fulls-alone arm left for the arm after it (corr is restored, g_full reset by the combine, device g/scaled cleared per call, the sweep / rejection / damage diagnostics reset where they are measured), so the merge is bit-identical to the one the pair returned. The log's decision line no longer quotes the fulls-alone ISa when that merge is not made. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5K8jvPPbmCrbqnWkddTuB
This commit is contained in:
@@ -386,7 +386,7 @@ The combine groups each reflection's partials into rocking events (contiguous ru
|
||||
|
||||
The fulls are then re-scaled in the XDS sense — a per-image scale refit directly on the complete reflections under the unity partiality model — and merged (§10.4).
|
||||
|
||||
Before the combine a per-frame scale can also be fitted on the partials themselves. That needs a frame to hold many rocking events caught at different points of their curves: within one rocking curve a change of scale and an error of the partiality model are the same thing, and on a finely sliced sparse sweep the fit takes one for the other. The rocking events per frame are the prior (the partials are scaled from 50 events per frame), but the counts of small-molecule sweeps (3–43) and proteins (5–900) overlap. So the first merge of a run that is not a space-group search is made **both ways**, with the partials scaled and with the scale taken from the fulls alone, and the prior stands unless its merge has no resolved error model ($b$ not resolved from zero: the strong equivalents do not agree to within a measurable systematic error) while the other merge has one. The two ISa values are deliberately not compared beyond that: the partiality-model error a partial scale takes up is shared by symmetry mates measured at the same rocking geometry, so their agreement cannot see it — a small-molecule sweep with six events per frame read ISa 10.5 with its partials scaled against 8.7 from the fulls alone, and refined to $R_1$ 0.105 against 0.062. The log states the choice and both arms' ISa. Because every merged observation is now a counting-statistics-limited full rather than a partiality-divided slice, the error model reaches a far higher asymptotic $I/\sigma$.
|
||||
Before the combine a per-frame scale can also be fitted on the partials themselves. That needs a frame to hold many rocking events caught at different points of their curves: within one rocking curve a change of scale and an error of the partiality model are the same thing, and on a finely sliced sparse sweep the fit takes one for the other. The rocking events per frame are the prior (the partials are scaled from 50 events per frame), but the counts of small-molecule sweeps (3–43) and proteins (5–900) overlap. So the first merge of a run that is not a space-group search can be made **both ways**, with the partials scaled and with the scale taken from the fulls alone, and the prior stands unless its merge has no resolved error model ($b$ not resolved from zero: the strong equivalents do not agree to within a measurable systematic error) while the other merge has one. Where the prior is to scale the partials and that merge resolves its error model, the other cannot change the choice and is not made. The two ISa values are deliberately not compared beyond that: the partiality-model error a partial scale takes up is shared by symmetry mates measured at the same rocking geometry, so their agreement cannot see it — a small-molecule sweep with six events per frame read ISa 10.5 with its partials scaled against 8.7 from the fulls alone, and refined to $R_1$ 0.105 against 0.062. The log states the choice and the ISa of every arm that was made. Because every merged observation is now a counting-statistics-limited full rather than a partiality-divided slice, the error model reaches a far higher asymptotic $I/\sigma$.
|
||||
|
||||
How smooth that scale is over the rotation is left to the data rather than to a fixed window. Each round fits every frame on its own fulls against the current reference, giving a scale $G_f$ and its information $D_f=\sum w^2c^2$; the scale is then the curve $x=\log G$ minimising $\sum_f J_f\,(x_f-y_f)^2+\lambda\sum_f(\Delta^2 x)_f^2$, with $y_f$ the frame's own fit in log scale and $J_f$ its information carried there — a penalised (Whittaker–Eilers) smoother, solved as a five-band linear system. $\lambda$ is chosen by cross-validation: blocks of frames one rocking curve wide are left out in turn and predicted from the curve through the rest (neighbours closer than a rocking curve share their measurement, since a full sums those frames). A frame of hundreds of fulls is then followed frame by frame, a frame of two or three is carried by its neighbours, a stretch with none is bridged by a straight line, and a scale that falls a hundredfold over a few degrees — an absorbing crystal turning edge-on — is followed where a window would average across it. Once the curve settles, one free fit is shrunk toward it frame by frame by how much of each frame's deviation its neighbour shares (the lag-1 covariance), which hands back a real per-frame systematic and discards fit noise.
|
||||
|
||||
|
||||
@@ -5829,10 +5829,14 @@ namespace {
|
||||
// rocking events per frame the partials' scale read ISa 10.5 against 8.7 from the fulls alone, and
|
||||
// SHELXL R1 0.105 against 0.062 - the partiality-model error the partial scale takes up is shared by
|
||||
// symmetry mates measured at the same rocking geometry, so their agreement cannot see it.
|
||||
bool ErrorModelResolved(const RotationScaleMerge::Result &r) {
|
||||
return r.isa_resolved && std::isfinite(r.isa) && r.isa > 0.0;
|
||||
}
|
||||
|
||||
bool PartialScalingWins(const RotationScaleMerge::Result &with, const RotationScaleMerge::Result &without,
|
||||
bool prior) {
|
||||
const bool w_ok = with.isa_resolved && std::isfinite(with.isa) && with.isa > 0.0;
|
||||
const bool o_ok = without.isa_resolved && std::isfinite(without.isa) && without.isa > 0.0;
|
||||
const bool w_ok = ErrorModelResolved(with);
|
||||
const bool o_ok = ErrorModelResolved(without);
|
||||
const bool prior_ok = prior ? w_ok : o_ok;
|
||||
const bool other_ok = prior ? o_ok : w_ok;
|
||||
return prior_ok || !other_ok ? prior : !prior;
|
||||
@@ -5850,12 +5854,28 @@ RotationScaleMerge::Result RotationScaleMerge::Run(bool for_search, bool full_st
|
||||
// where the other did not (PartialScalingWins); later merges keep that choice. A search merge is in
|
||||
// P1 or a subgroup, where a reflection has two or three observations, so it takes the prior. The arm
|
||||
// returned is the one run last, so that every per-pass state the merge leaves behind is that arm's.
|
||||
//
|
||||
// Where the prior is to scale the partials and that merge resolves its error model, the other arm
|
||||
// cannot change the choice, so it is not made. The partials' arm then starts from G = 1, which is
|
||||
// what the fulls-alone arm would have left it (it is the only state that arm leaves for the next),
|
||||
// so the merge is the one the pair would have returned.
|
||||
if (rocking_event_frames_at_start < 0)
|
||||
rocking_event_frames_at_start = RockingEventFrames(&rocking_events_at_start);
|
||||
const double events_per_frame = n_frames > 0 ? static_cast<double>(rocking_events_at_start) / n_frames : 0.0;
|
||||
const bool prior = events_per_frame >= MIN_EVENTS_PER_FRAME_SCALE_PARTIALS;
|
||||
if (for_search && !scale_partials_choice)
|
||||
return RunArm(prior, for_search, full_stats, measure_cc_before_corrections);
|
||||
if (!scale_partials_choice && prior) {
|
||||
std::fill(g_partial.begin(), g_partial.end(), 1.0);
|
||||
Result with = RunArm(true, for_search, full_stats, measure_cc_before_corrections);
|
||||
if (ErrorModelResolved(with)) {
|
||||
scale_partials_choice = true;
|
||||
logger.Info("Per-frame scale: fitted on the partials ({:.1f} rocking events per frame) - ISa {:.1f} "
|
||||
"with the partials scaled; the fulls-alone merge is made only when this one has no "
|
||||
"resolved error model", events_per_frame, with.isa);
|
||||
return with;
|
||||
}
|
||||
}
|
||||
if (!scale_partials_choice) {
|
||||
Result without = RunArm(false, for_search, full_stats, measure_cc_before_corrections);
|
||||
Result with = RunArm(true, for_search, full_stats, measure_cc_before_corrections);
|
||||
|
||||
Reference in New Issue
Block a user