merge: bridge a rocking event by an angle rather than by a frame count

A rotation reflection arrives as a run of partials and is cut into rocking events
wherever two successive frames are more than MAX_FRAME_GAP apart. That constant
was 2.0 frames, in five places, and the quantity it stands for is not a frame
count at all: it is an angle, a reflecting range of a tenth to half a degree. At a
tenth of a degree per image two frames is a fifth of a degree of dead rotation and
the bridge does what it was meant to; at a degree per image it is two degrees, as
wide as a whole event, and two genuine crossings of the Ewald sphere are joined
into one "full". Reconstructing the events on a paired control - the same photons
sliced two ways - the fraction of events whose partialities sum past 1.5 is 14.3%
at 1.0 degree against 6.0% at 0.1, the fused ones spanning a median of five frames.

So the gap becomes half a degree of rotation, floored at one frame so an event is
never cut at its own neighbours and capped at the two frames that were always
allowed. For any oscillation of 0.25 degrees or less the quotient is at least two
and the cap returns the literal 2.0f, so every finely sliced sweep is bridged
exactly as before, on both the CPU and the GPU path.

The value is taken once per run and carried to the device in CombineParams rather
than recomputed in the kernel, so the two paths compare the same float by
construction and the bit-parity contract the combine documents is preserved.

The defect above is measured; the repair is not. No arm has been run with this
change, which is why it is a commit of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
This commit is contained in:
2026-09-04 10:09:08 +02:00
co-authored by Claude Opus 5
parent 91775cc4fb
commit 555c686205
12 changed files with 54 additions and 23 deletions
+7 -5
View File
@@ -598,7 +598,7 @@ float DetectorY(const Reflection &r) { return std::isfinite(r.observed_y) ? r.ob
// Sum each rocking event into one full observation. A rotation reflection is integrated image by
// image, so it arrives here as a run of partials over consecutive frames; the run is cut where the
// 3D combine cuts it - same raw hkl, frames no further apart than MAX_FRAME_GAP - so the exported
// 3D combine cuts it - same raw hkl, frames no further apart than RockingEventFrameGap - so the exported
// file and rugnux's own merge see exactly the same events.
// The parts are added, plainly, with their variances in quadrature, which is what every other
// rotation program writes as a full. Nothing is divided by the partiality: FRACTIONCALC carries the
@@ -630,8 +630,9 @@ float DetectorY(const Reflection &r) { return std::isfinite(r.observed_y) ? r.ob
// mosaicity, RotationScaleMerge::SmoothMosaicity), so clamping it to 1 would hide the spread and buy
// a reading program nothing.
std::vector<Reflection> SumRockingEvents(const std::vector<IntegrationOutcome> &outcomes,
double min_partiality, double min_captured_fraction) {
constexpr float MAX_FRAME_GAP = 2.0f; // == RotationScaleMerge's: what makes one rocking event
double min_partiality, double min_captured_fraction,
float wedge_deg) {
const float max_frame_gap = RockingEventFrameGap(wedge_deg); // == RotationScaleMerge's
// The sort key travels with the part instead of being read back through the pointer, the way the
// merge's own ingest sort carries it (RotationScaleMerge's SortKey): there are millions of parts
@@ -661,7 +662,7 @@ std::vector<Reflection> SumRockingEvents(const std::vector<IntegrationOutcome> &
size_t j = i + 1;
while (j < parts.size() && parts[j].h == parts[i].h && parts[j].k == parts[i].k
&& parts[j].l == parts[i].l
&& parts[j].image_number - parts[j - 1].image_number <= MAX_FRAME_GAP)
&& parts[j].image_number - parts[j - 1].image_number <= max_frame_gap)
++j;
double sum_p = 0.0, sum_I = 0.0, sum_var = 0.0, sum_var_bkg = 0.0;
@@ -815,7 +816,8 @@ void WriteUnmergedMtzReflections(const std::vector<IntegrationOutcome> &outcomes
if (scanning && sum_partials) {
for (const auto &r : SumRockingEvents(outcomes,
experiment.GetScalingSettings().GetMinPartiality(),
experiment.GetScalingSettings().GetMinCapturedFraction()))
experiment.GetScalingSettings().GetMinCapturedFraction(),
wedge_deg))
add_row(r);
} else {
for (const auto &outcome : outcomes)