Files
Jungfraujoch/rugnux/RugnuxDefaults.cpp
T
leonarski_f 711f902aa7 One gain mechanism: frames analysed in their own units, the gain applied where a photon count is assumed
The gain of an input in detector units (SMV, marCCD, XTC, the XFEL formats) is measured once, before
anything reads the frames (RugnuxMeasureGain: photon-peak spacing and background variance per unit
mean), or given with --gain, and handed to the integration and the spot finding by one function
(ApplyRugnuxPixelGain). The rotation pre-scan no longer measures it, and the stills reader no longer
converts frames of a detector whose photons are not resolved: SACLA MPCCD, Rayonix and CCD frames are
integrated in ADU at the gain, rotation and stills alike.

Where a photon count was assumed, it is now taken at the gain (bit-identical at gain 1):
- integration: the background clip's one-photon floor, the sigma floor (one photon), the profile fit's
  background floor (0.01 photon), and a still's overload kept as a lower bound, which was left in ADU
  while every other reflection left the integrator in photons - the cause of the earlier loss of the
  engine route on clipped CCD stills (merged low resolution inflated by the gain, the rocking-width
  fit's bandwidth collapsing); CPU and CUDA, Metal through the shared CPU code;
- the adaptive spot threshold (Poisson arm and its one-photon floor), CPU and GPU;
- the pre-scan spot-width measurement (strong-spot SNR, the ring clip) behind the stills bandwidth and
  the rotation signal radius;
- the hot/dead pixel tests, CPU and GPU; the empty-aperture ring clip.
BackgroundPixelGain is gone: one pixel_gain.

A detector whose background resolves single photons (photon peaks: JUNGFRAU in keV, AGIPD) is a photon
counter and its frames are still counted in whole photons as read: analysed in keV at the gain instead,
a photon-counting still set lost 0.008 R-free (fewer spots, a noisier beam-centre trial) - the rounding
is the detector's own discrimination. --gain sets the value, the background still says which kind.

Validation, against rc178: photon-counting and g<=1 rotation p.hkl md5-identical (lyso, thau, cytc,
myob, insu, a small-molecule set, 5reo, 6cee, 6i3j, 2xfw, 6s1u, 8v4j); CCD g>1 at or within noise of rc178 (5uth
R_meas 0.113 -> 0.108). Stills R-free (0-cycle REFMAC): MPCCD sets 0.184 -> 0.185 and 0.199 -> 0.196,
Rayonix identical, photon-resolving sets back on their rc178 numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit da8dbfc8147354b63e375f2d9521dffb80938b6d)
2026-10-23 17:56:51 +02:00

154 lines
7.9 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "RugnuxDefaults.h"
#include <algorithm>
#include <cstring>
namespace {
// Smoothing range for the per-frame scale G on rotation data (XDS DELPHI-like), in degrees.
constexpr double SMOOTH_G_DEG = 5.0;
// Per-observation outlier rejection for the rotation combine, in sigma.
constexpr double REJECT_OUTLIERS_NSIGMA = 6.0;
}
void ApplyRugnuxExperimentDefaults(DiffractionExperiment &experiment) {
// The Lp correction the integrator applies to every reflection. Without it the intensities carry an
// azimuthal, intensity-proportional modulation (~1.3x at 2 A for a 1 A beam) that symmetry mates do
// NOT share, because they sit at the same 2-theta but a different azimuth - which is exactly the
// "unequal intensities forced together" signature the space-group search vetoes as pseudo-symmetry.
// Only where the file stated none: a miniCBF or d*TREK header says what its source was, and a
// laboratory tube there says unpolarized.
if (!experiment.GetPolarizationFactor().has_value())
experiment.PolarizationFactor(0.99);
// The background ring's radial curvature correction: auto, applied per image where the ice score
// sees a smooth ring. The library default stays off (the broker keeps it off); offline, over ice
// sets and clean controls, auto was a clear gain where it fired (smooth ice: CC to the model +0.015,
// R shell-scaled -0.019 on one set) and bit-identical where it did not, while forcing it on
// everywhere cost a clean set 0.008 in CC to the model.
BraggIntegrationSettings bis = experiment.GetBraggIntegrationSettings();
bis.BackgroundRadialCorrection(std::nullopt);
experiment.ImportBraggIntegrationSettings(bis);
}
void ClearStoredCrystal(DiffractionExperiment &experiment) {
experiment.SpaceGroupNumber(std::nullopt);
experiment.SetUnitCell(std::nullopt);
}
ScalingSettings RugnuxDefaultScalingSettings(bool rotation) {
ScalingSettings s;
// Refit the per-frame scale on the combined fulls. Rotation only - it is what lifts ISa there, and
// there is no combine on the stills path.
s.ScaleFulls(rotation);
s.SmoothGDegrees(rotation ? SMOOTH_G_DEG : 0.0);
// Drop edge-of-sweep fulls whose rocking curve was captured less than this. 0.7 rather than 0.5 also
// strips the partiality-extrapolated fulls that dominate the intensity second moment on weakly
// diffracting crystals, without which the de-novo space-group search is starved by the error-model
// I/sigma floor and falls back to P1.
s.MinCapturedFraction(rotation ? 0.7 : 0.0);
// Capture-aware systematic sigma: down-weights the over-extrapolated under-captured fulls.
s.CaptureUncertaintyCoeff(rotation ? 1.0 : 0.0);
// A single un-rejected artifact wrecks the intensity-correlation and second-moment statistics the
// space-group and twinning tests are built on.
s.OutlierRejectNsigma(rotation ? REJECT_OUTLIERS_NSIGMA : 0.0);
// Run the de-novo space-group search a second time on a merge of only the well-measured
// observations and keep whichever found more symmetry. It cannot lose symmetry, so an imperfect cut
// only means the second opinion contributes nothing.
s.SearchMinZeta(rotation ? 0.85 : 0.0);
return s;
}
bool RugnuxRotationData(const DiffractionExperiment &experiment) {
const auto gonio = experiment.GetGoniometer();
return gonio.has_value() && gonio->IsScanning();
}
SpotFindingSettings RugnuxDefaultSpotFinding(bool rotation) {
SpotFindingSettings s;
s.enable = true;
s.indexing = true;
s.adaptive_threshold = true;
// The high-resolution limit stays unset - as far as the detector reaches - for rotation as well as
// stills. Rotation used to keep 1.5 A on the strength of an indexing-rate measurement, but over the
// 33-crystal battery that limit changes nothing on 29 crystals, changes no space-group decision at
// all, and on the crystals where it does bite it is the LIMIT that is worse: the one crystal that
// loses appreciable indexing rate without it (99.5 -> 94.2%) comes back with better R_meas, better
// high-resolution CC1/2 and better ISa. Fewer frames, better data.
s.high_resolution_limit = std::nullopt;
s.min_pix_per_spot = rotation ? std::optional<int64_t>(2) : std::nullopt;
return s;
}
void ApplyRugnuxIntegrationRadii(DiffractionExperiment &experiment, bool rotation) {
if (rotation)
return;
// Stills spots span a range of crystal orientations captured in a single shot, so they land
// wider on the detector than the r1=4 monochromatic-rotation default assumes. A larger signal
// box lets the profile-fit integrator capture the whole spot while its profile weighting keeps
// the extra background from adding noise (a plain box-sum degrades with it). Measured R-free
// gains on serial stills.
//
// r3 = 14 rather than 12: the reported sigma carries the ring mean's own error as
// (dI/dbkg)^2 * bkg/n_ring, and the ring only holds 244 pixels at r3 = 12 against the
// rotation default's 408, so that term is a larger share of the variance here than
// anywhere else. 14 takes the ring to about 410 pixels. What normally stops a ring being
// widened is crowding - a neighbour inside the annulus is excluded from it - and stills
// barely crowd: under a percent of stills reflections have any neighbour within 13 px,
// against most of them on rotation.
BraggIntegrationSettings bis = experiment.GetBraggIntegrationSettings();
bis.R1(6.0f).R2(8.0f).R3(14.0f);
experiment.ImportBraggIntegrationSettings(bis);
}
bool RugnuxDetectIceRings(std::optional<bool> file_detect_ice_rings, bool rotation) {
return file_detect_ice_rings.value_or(rotation);
}
RugnuxMeasuredGain RugnuxMeasureGain(JFJochReader &reader) {
const auto dataset = reader.GetDataset();
const size_t n = reader.GetNumberOfImages();
const size_t frames = std::min<size_t>(n, background_gain::GAIN_MAX_FRAMES);
std::vector<background_gain::Levels> levels;
std::vector<uint64_t> histogram(background_gain::PEAK_HISTOGRAM_BINS, 0);
JFJochReaderRawImage raw;
std::vector<uint8_t> buffer;
std::vector<int32_t> pixels;
for (size_t i = 0; i < frames; i++) {
if (!reader.ReadRawImage(static_cast<int64_t>(i * n / frames), raw))
continue;
const size_t w = raw.image.GetWidth(), h = raw.image.GetHeight();
pixels.resize(w * h);
const uint8_t *ptr = raw.image.GetUncompressedPtr(buffer);
if (raw.image.GetMode() == CompressedImageMode::Int32)
std::memcpy(pixels.data(), ptr, w * h * sizeof(int32_t));
else if (raw.image.GetMode() == CompressedImageMode::Int16)
std::copy_n(reinterpret_cast<const int16_t *>(ptr), w * h, pixels.begin());
else if (raw.image.GetMode() == CompressedImageMode::Uint16)
std::copy_n(reinterpret_cast<const uint16_t *>(ptr), w * h, pixels.begin());
else
continue;
levels.push_back(background_gain::MeasureFrame(pixels, w, h, dataset->pixel_mask->GetMask(),
dataset->experiment.GetSaturationLimit()));
background_gain::AddToHistogram(pixels, dataset->pixel_mask->GetMask(),
dataset->experiment.GetSaturationLimit(), histogram);
}
return {background_gain::PhotonPeak(histogram), background_gain::Gain(levels)};
}
void ApplyRugnuxPixelGain(DiffractionExperiment &experiment, SpotFindingSettings &spot_finding, float adu_per_photon) {
const float g = std::max(1.0f, adu_per_photon);
BraggIntegrationSettings bis = experiment.GetBraggIntegrationSettings();
bis.PixelGain(g);
experiment.ImportBraggIntegrationSettings(bis);
spot_finding.adu_per_photon = g;
}