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)
154 lines
7.9 KiB
C++
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;
|
|
}
|