Rotation: pass 2 divides partiality by the width pass 2 measured

The second pass of the two-pass rotation loop widens its prediction window to
the frame-order-smoothed mosaicity the first pass fitted, taking the max with
this frame's own estimate so no reflection is dropped. That widened value was
then reported onward as the frame's mosaicity, so it also became the divisor
RotationScaleMerge recomputes every partiality from - a number chosen for
prediction safety, applied to the intensities.

The two uses are not symmetric. Prediction only decides membership: a
reflection just inside a generous window arrives with a partiality near zero
and is weighted as such, and widening the window by 28% was measured to move
the merged statistics by under half a percent. The divisor multiplies every
partial, and forcing it 34% wide on a rotation crystal cost ISa 10.7 -> 8.0 and
R_meas 12.7 -> 14.5%. Pass 2 has also just re-measured the rocking width
against the post-refined cell, which is the better of the two numbers - the
literature's own remedy for a mosaicity fitted against a stale cell is exactly
to re-estimate it after post-refinement (XDS documents this as a manual second
INTEGRATE/CORRECT round).

So keep the widened value where it was wanted, on the prediction window, and
let the partiality divide by what the frame measured. The predicted reflection
population is unchanged.

Measured on three rotation crystals at their XDS resolution limits, identical
partial counts in every arm: a weak monoclinic ISa 6.3 -> 6.7, R_meas
19.1 -> 18.7%, <I/sigma> 3.3 -> 3.5; tetragonal lysozyme ISa 27.1 -> 27.4,
R_meas unchanged at 4.5%; a second monoclinic ISa 13.9 -> 13.8, R_meas
unchanged at 12.3%.

The full 38-crystal rotation battery then says the defect was almost never
active: 36 of 38 crystals are untouched, and the reported per-image mosaicity
moves on exactly one of them (0.3380 -> 0.3385 deg). The three-crystal probe
above does not reproduce against the current baseline - lysozyme reads
27.81 -> 27.80 - because the max() rarely bites: pass 1's per-frame width is
already smooth in frame order, its median frame-to-frame step being 0.0005 deg
against a run spread of 0.028, so it seldom exceeds what pass 2 measures for
itself. The two crystals that do move are marginal ones whose two-pass lattice
search takes a different branch (validation frames 25/60 -> 26/60 and
48/60 -> 49/60); on that evidence their merge numbers measure the branch, not
this change. No space group moves.

So this lands as a correctness fix with no measurable effect on today's data,
not as an improvement: the widened window must not become the divisor, whether
or not the two happen to coincide on the crystals we have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 01:18:08 +02:00
co-authored by Claude Opus 5
parent a0604ff244
commit e0e429ae2f
2 changed files with 11 additions and 9 deletions
+10 -8
View File
@@ -457,6 +457,13 @@ void IndexAndRefine::QuickPredictAndIntegrate(DataMessage &msg,
// report that default onward: RotationScaleMerge averages the reported values in frame order to
// recompute every partiality, and a placeholder entered there is read as a measurement.
std::optional<float> mos_measured;
// The width the PREDICTION window opens to, which is a different question from the width the
// partiality divides by. Prediction only decides membership, and a reflection just inside a
// generous window arrives with a partiality near zero and is weighted accordingly, so erring wide
// there is close to free (measured: a 28% wider window moves the merged statistics by under half a
// percent). The divisor is not free - it multiplies every partial - so it must stay on the width
// this frame actually measured.
std::optional<float> mos_predict;
if (experiment.GetGoniometer().has_value()) {
// Full oscillation wedge of one frame; BraggPredictionRot halves it to the +/- half-wedge of the
@@ -474,15 +481,10 @@ void IndexAndRefine::QuickPredictAndIntegrate(DataMessage &msg,
// reflections and collapse the multiplicity. (A wider value only helps prediction cover the spot.)
if (msg.number >= 0 && msg.number < static_cast<int64_t>(prediction_mosaicity_override_.size())
&& std::isfinite(prediction_mosaicity_override_[msg.number])
&& prediction_mosaicity_override_[msg.number] > 0.0f) {
mos_measured = std::max(mos_measured.value_or(0.0f), prediction_mosaicity_override_[msg.number]);
mosaicity[msg.number] = *mos_measured;
}
&& prediction_mosaicity_override_[msg.number] > 0.0f)
mos_predict = std::max(mos_measured.value_or(0.0f), prediction_mosaicity_override_[msg.number]);
}
// The width the prediction window opens to. It is the measured sigma_M unless a fixed prediction
// mosaicity was asked for: the reported (and therefore integrated-against) sigma_M below is left
// alone either way, so the two uses of the number can be separated.
float mos_deg = mos_measured.value_or(0.1f);
float mos_deg = mos_predict.value_or(mos_measured.value_or(0.1f));
if (const auto forced_pred = experiment.GetBraggIntegrationSettings().GetForcedPredictionMosaicity_deg())
mos_deg = *forced_pred;
+1 -1
View File
@@ -149,7 +149,7 @@ void print_usage() {
std::cout << " --capture-uncertainty <num> rot3d: systematic sigma ~num*(1-captured_fraction)*I on under-captured fulls (default: 1.0 for rot3d, 0 otherwise)" << std::endl;
std::cout << " --min-captured-fraction <num> rot3d: drop a combined full whose rocking curve was captured below this fraction (edge-of-sweep truncated fulls) (default: 0.7 for rotation, 0 otherwise; 0 = off)" << std::endl;
std::cout << " --mosaicity <num> Diagnostic: fix the scaling mosaicity (deg) instead of the per-image seed" << std::endl;
std::cout << " --prediction-mosaicity <num> Diagnostic: fix the rocking width the PREDICTION window opens to (deg), leaving the partiality on the per-image sigma_M. Prediction and partiality are one number by default, so a sigma_M that moves takes the integrated reflection population with it" << std::endl;
std::cout << " --prediction-mosaicity <num> Diagnostic: fix the rocking width the PREDICTION window opens to (deg), leaving the partiality on the per-image sigma_M. Prediction and partiality are one number by default, so a sigma_M that moves takes the integrated reflection population with it" << std::endl;
std::cout << " --reject-outliers <num> Per-observation merge outlier rejection, N sigma from the per-reflection median (default: 6 for rot3d, XDS/DIALS-style; 0 = off)" << std::endl;
std::cout << " --search-min-zeta <num> De-novo space-group search only: also search a merge of just the observations whose Lorentz geometry |zeta| reaches this, and keep whichever search found MORE symmetry (default: 0.85 for rotation, 0 = single search). Reflections crossing the Ewald sphere near-tangentially are measured worst and can make a real symmetry operator look like a twin law" << std::endl;
std::cout << " --min-image-cc <num> Per-image CC limit in percent (default: no limit)" << std::endl;