// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include "RugnuxDefaults.h" #include #include 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(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 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(n, background_gain::GAIN_MAX_FRAMES); std::vector levels; std::vector histogram(background_gain::PEAK_HISTOGRAM_BINS, 0); JFJochReaderRawImage raw; std::vector buffer; std::vector pixels; for (size_t i = 0; i < frames; i++) { if (!reader.ReadRawImage(static_cast(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(ptr), w * h, pixels.begin()); else if (raw.image.GetMode() == CompressedImageMode::Uint16) std::copy_n(reinterpret_cast(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; }