Scaling: one exact-Bragg angle per rocking event

Every partial's delta_phi was solved from its OWN frame's lattice - by the predictor,
and again by SmoothGeometry. Per-frame geometry is re-refined against that frame's spots
alone, so what is left of its jitter entered each frame of an event independently and the
frames of one rocking event stopped sitting exactly one oscillation apart on the curve.
Their partialities then no longer tile it, and because a broad rocking curve spans more
frames, the error grows as 1/zeta - which is how it has been showing up: a zeta-graded
systematic that nothing in the integrator could reach.

For the frames of one event the geometry is exact. Each frame has already turned one
oscillation further, so delta_phi is linear in frame number with slope minus the
increment; the sign is checked against the data rather than derived, the measured mean
frame-to-frame slope being -0.19996 deg/frame at an increment of 0.20000. Fit the one
free number, the offset, over the event and lay its partials back on that line. The rms
departure removed is 0.25 deg - larger than the oscillation itself, because a small
orientation wobble is amplified by 1/zeta. The raw-hkl runs the merge already builds give
the grouping, so this costs one pass over the partials and no extra sort.

Full 38-crystal rotation battery against the same binary without it, on unchanged data
(observations +0.20%, unique reflections +0.03%, so none of this is selection):

  R_meas_lo   better 22 / worse 5, summed -41.0 pp; excess against XDS -46.9 -> -87.9
  R_meas      better 22 / worse 5, summed -24.7
  CC1/2       better 17 / worse 2,  summed +36.8
  ISa         better 15 / worse 22, summed +6.87; shortfall against XDS 28.1 -> 21.2
  space groups unchanged at 35/38

The low-resolution R_meas gains land on the crystals that have carried this gap: 19.3 ->
10.8, 20.7 -> 13.8 (now past XDS), 25.6 -> 19.7, 17.5 -> 12.4 per cent. Exactly one
crystal shows any change in the two-pass branch fingerprint, so unlike most changes on
this path the result is not confounded by that bistability.

ISa falls on more crystals than it rises, and that is the estimator becoming honest
rather than the data getting worse: every crystal whose ISa dropped materially was
over-optimistic against its own R_meas_lo and moved toward consistency, and the median
ratio of reported ISa to the value its own R_meas_lo implies goes 1.09 -> 1.01, against
1.19 for XDS. The one real loss is a crystal going 1.11 -> 0.95 on that ratio.

High-shell CC1/2 is worse on 22 crystals, by about 1.2 points each. It is the one metric
that dissents, and it is also the one that has failed as an arbiter repeatedly on this
data, while overall CC1/2, R_meas, R_meas_lo and reflection count all improve on an
unchanged number of observations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 05:25:47 +02:00
co-authored by Claude Opus 5
parent 6f7b136ec2
commit b40abe31cf
2 changed files with 59 additions and 0 deletions
+1
View File
@@ -5,6 +5,7 @@ This is an UNSTABLE release. It includes many experimental features, as well as
* rugnux: The error-model **a** and **b** are reported in XDS's convention, and `_reflns.jfjoch_diffrn_ISa` now carries the whole-range `1/sqrt(a*b)` that XDS's ISa denotes; the strong-reflection asymptote moves to `_reflns.jfjoch_diffrn_ISa_asymptotic`. **A file written by an earlier version carries the asymptote under the old name.**
* Bragg integration: the background ring's outer radius default changes from 10 px to **13 px**, which roughly doubles the pixels behind each background estimate; the signal disk is unchanged.
* Scaling: the partials of one rocking event now share a single exact-Bragg angle, instead of each solving its own from its frame's refined orientation, which had been feeding per-frame refinement noise into partiality in proportion to 1/zeta.
* Bragg integration: signal pixels shared with a neighbouring reflection are now dropped from the profile fit (`--overlap off|reject|exclude`, **default `exclude`**), so a crowded reflection no longer reads its neighbour's flux as its own; `reject` instead discards a reflection whose cleanly-observed profile fraction is below `--overlap-minpk` (default 0.75).
* rugnux: The background ring can be **elongated radially per reflection** for broadband data (`--integration-stencil <k>`, default 0 = the fixed circular ring), by `k` times the beam's radial streak; the `r1` signal box stays circular.
* rugnux: New **beam-stop shadow detection**, **on by default** (`--detect-beam-stop[=N|off]`), finds the beam stop and its holder in a projection of N images (default 60) and adds them to the pixel mask as bit 9, which is cleared at the start of every run.
@@ -556,6 +556,64 @@ void RotationScaleMerge::SmoothGeometry() {
void RotationScaleMerge::SmoothMosaicityAndPartiality() {
SmoothGeometry();
// One rocking event, one exact-Bragg angle. Every partial's delta_phi is solved from its OWN
// frame's lattice - by the predictor, and again by SmoothGeometry above - so the per-frame
// orientation jitter enters each frame of an event independently and the frames stop sitting
// exactly one oscillation apart on the rocking curve.
// Their partialities then no longer tile it, and the error grows with the number of frames the
// event spans - which is 1/zeta - so it appears as a zeta-graded systematic that nothing in the
// integrator can reach. For the frames of ONE event the geometry is exact: each frame has already
// turned one oscillation further, so delta_phi(f) is linear in f with slope MINUS the increment.
// Sign checked against the data rather than derived - the measured mean frame-to-frame slope is
// -0.19996 deg/frame at an increment of 0.20000. So fit the only free number, the offset,
// averaging the jitter over the event, and lay the partials back on that line. Measured rms
// departure from it: 0.25 deg, larger than the oscillation itself, because a small orientation
// wobble is amplified by 1/zeta.
const auto gon_ev = x.GetGoniometer();
const double increment_deg = gon_ev ? gon_ev->GetIncrement_deg() : 0.0;
if (std::fabs(increment_deg) > 0.0) {
std::atomic<int64_t> n_events{0}, n_partials{0};
ParallelChunks(static_cast<int>(rawrun_start.size()), nthreads, [&](int lo_r, int hi_r) {
int64_t l_ev = 0, l_pa = 0;
for (int r = lo_r; r < hi_r; ++r) {
const int lo = rawrun_start[r], hi = lo + rawrun_count[r];
int i = lo;
while (i < hi) {
int j = i + 1;
while (j < hi && partials[perm[j]].image_number
- partials[perm[j - 1]].image_number <= MAX_FRAME_GAP)
++j;
if (j - i >= 2) {
const float f0 = partials[perm[i]].image_number;
double sum = 0.0;
int n = 0;
for (int m = i; m < j; ++m) {
const auto &o = partials[perm[m]];
if (!std::isfinite(o.delta_phi)) continue;
sum += o.delta_phi + increment_deg * (o.image_number - f0);
++n;
}
if (n >= 2) {
const double c = sum / n;
for (int m = i; m < j; ++m) {
auto &o = partials[perm[m]];
o.delta_phi = static_cast<float>(c - increment_deg
* (o.image_number - f0));
}
++l_ev;
l_pa += j - i;
}
}
i = j;
}
}
n_events += l_ev;
n_partials += l_pa;
});
logger.Info("One exact-Bragg angle per rocking event: {} events, {} partials relaid",
n_events.load(), n_partials.load());
}
// Per-frame mosaicity to recompute partiality from. A forced (fixed) mosaicity overrides every frame;
// otherwise use the per-frame value measured at integration (image-local, deterministic).
const auto forced_mosaicity = x.GetScalingSettings().GetForcedMosaicity();