Files
Jungfraujoch/image_analysis/scale_merge/RotationScaleMerge.h
T
leonarski_f 67dca388bd
Build Packages / Unit tests (push) Skipped
Build Packages / build:windows:cuda (push) Successful in 18m44s
Build Packages / build:viewer-tgz:cpu (push) Successful in 6m11s
Build Packages / build:viewer-tgz:cuda (push) Successful in 6m54s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 9m40s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 10m41s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 10m10s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 10m4s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 11m5s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 12m23s
Build Packages / build:rpm (rocky8) (push) Successful in 11m30s
Build Packages / build:rpm (rocky9) (push) Successful in 12m51s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 12m8s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 11m21s
Build Packages / DIALS test (push) Successful in 13m22s
Build Packages / XDS test (durin plugin) (push) Successful in 9m2s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 7m55s
Build Packages / XDS test (neggia plugin) (push) Successful in 5m57s
Build Packages / Generate python client (push) Successful in 23s
Build Packages / Build documentation (push) Successful in 57s
Build Packages / Create release (push) Skipped
Build Packages / build:windows:nocuda (push) Successful in 10m24s
v1.0.0-rc.160 (#70)
This is an UNSTABLE release. It includes many experimental features, as well as many AI generated fixes. We recommend using rc.152 for production use.

* rugnux: Add `--model model.pdb` - score the merged data against an atomic model and compute initial maps. It reports R-work/R-free (scaling the model to the observed amplitudes with an overall scale, an anisotropic B and a flat bulk solvent - the standard few-parameter model, so a batch of maps stays directly comparable) and writes 2Fo-Fc / Fo-Fc electron-density maps (CCP4) plus a map-coefficient MTZ. The structure itself is not refined; the model is only re-fractionalised into the data cell.
* rugnux: The merged reflection output now carries French-Wilson amplitudes (|F| and its sigma) next to the intensities - MTZ `F`/`SIGF`, mmCIF `_refln.F_meas_au`, and the text HKL - computed with the correct centric/acentric Wilson prior and epsilon multiplicity, so a downstream program (e.g. phenix.refine) can refine against amplitudes. The intensity columns are unchanged.
* rugnux: R-free test-set flags are now assigned deterministically and consistently across symmetry - a Bijvoet pair I(+)/I(-) is never split between the work and free sets, and the assignment is a reproducible per-hkl hash that depends only on the reflection index, so every dataset of one crystal form gets the same ~5% free set (what a multi-dataset campaign such as PanDDA needs). On small data the fraction is floored so the test set stays large enough for a stable R-free (~500 reflections, capped at 10%); it stays flat at 5% on ordinary data. When a reference MTZ carries a `FreeR_flag` column its test set is imported instead, letting a whole campaign inherit one shared free set.
* rugnux: A reference MTZ (`--reference-mtz`) can now fix the space group and cell for rotation data too (previously rejected), without being used to scale - the rotation merge stays self-consistent. When the crystal has an indexing (merohedral) ambiguity - a lattice symmetry higher than its Laue symmetry, e.g. P3/P4/P6/C2 - the reference also resolves it: each candidate reindexing (identity plus the twin-law cosets of the metric symmetry) is scored by its intensity correlation against the reference and the data are re-merged in the best-correlating one. This is a metric-preserving relabelling of hkl (the cell is unchanged) and a no-op for a holohedral crystal such as lysozyme.
* rugnux: `--model` validation now aligns the data to the model before scoring - the observed reflections are reindexed into the model's enantiomorph when the two differ only by hand (indistinguishable from merged intensities). A merohedral indexing ambiguity is resolved against the reference MTZ when one is given (so a whole campaign shares one indexing convention); only with a model and no reference does validation fall back to fitting each candidate reindexing and keeping the lowest R-free.
* rugnux: De-novo symmetry - recover a genuine high-symmetry group whose data are imperfectly scaled. Such a merge's within-orbit chi² lands just past the self-consistency bound (each real symmetry step adds a little systematic scatter), right where a merohedral twin also lands, so the chi² ratio alone cannot separate them. The candidate is now rescued when the extra intensity-proportional systematic error it invokes stays small relative to the confirmed subgroup - a genuine symmetry step gains multiplicity without inflating the merge error model's b, whereas a twin forces non-equivalent reflections together and b balloons. Fixes cubic insulin (I23 instead of I222) with no change to any other crystal in the test battery, including the twins that must stay in their lower symmetry.
* Docs: Document the French-Wilson amplitude estimation, R-free flagging, reference-based space-group/ambiguity resolution, and model-based validation/maps in CPU_DATA_ANALYSIS.md.
* Frontend: The status-bar pill now shows a progress bar during detector calibration (previously only during measurement), and the calibration state and its button are labelled "Calibration"/"CALIBRATE" (the internal `Pedestal` state name is unchanged for back-compatibility).Reviewed-on: #70

Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-07-19 09:39:28 +02:00

261 lines
16 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstdint>
#include <limits>
#include <optional>
#include <vector>
#include "../../common/DiffractionExperiment.h"
#include "../../common/Logger.h"
#include "../../common/Reflection.h"
#include "../../common/UnitCell.h"
#include "../IntegrationOutcome.h"
#include "Merge.h" // MergedReflection, MergeStatistics
#ifdef JFJOCH_USE_CUDA
#include <memory>
#include "RotationScaleMergeGPU.h"
#endif
// Dedicated, allocate-once scale+combine+merge for rotation data (the -P rot3d path): recompute the
// per-frame partiality from the (smoothed) mosaicity, robustly fit a per-image scale G, 3D-combine each
// rocking event's partials into fulls, refit a per-frame scale on the fulls (XDS order), and merge with
// a global error model.
//
// The per-frame partial observations are ingested ONCE into flat vectors; the hkl->ASU grouping is
// computed once per space group (by a sort, not a map) and reused across all scaling iterations; every
// hot step is a flat loop over those vectors, so the whole pipeline maps onto CUDA kernels (segmented
// reduction + per-frame solve) and runs GPU-resident when a GPU is present, with the CPU loops as the
// bit-parity fallback. CC1/2 and the per-image CC are computed once at the end, not every iteration.
//
// Used only for the self-scaling rotation case with per-image G (Rotation partiality, a fixed/forced
// mosaicity is honoured by the recompute). Post-scale-fulls correction stages (on by default via
// ScalingSettings::CorrectionSurfaces): a global Debye-Waller decay and a goniometer-frame absorption
// surface, both fitted on the host and pushed back to the resident (GPU) fulls before the merge.
// External-reference scaling, the stills B-factor and wedge refinement are unsupported (caller rejects).
// Stills use the per-image ScaleOnTheFly (fixed partiality) instead.
class RotationScaleMerge {
public:
struct Result {
std::vector<MergedReflection> merged;
MergeStatistics statistics;
double isa = 0.0; // 1/b of the fitted error model (0 if the model stayed at identity)
};
// experiment: read live (its space group is changed by the caller between Run() calls).
// partial_outcomes: the per-frame partials; the final per-frame scale (G, CC, mosaicity) is written
// back onto them so the offline per-image scaling table is still exported.
// reference_cell: the consensus cell (for the completeness count and the cell-consistency mask).
RotationScaleMerge(const DiffractionExperiment &experiment,
std::vector<IntegrationOutcome> &partial_outcomes,
std::optional<UnitCell> reference_cell,
int scaling_iterations,
float ice_ring_half_width_q,
size_t nthreads,
Logger &logger,
std::string observation_dump_path = {});
// Copy the per-frame partials into the flat buffers. Call once before the first Run().
void Ingest();
// Scale (per-frame G) -> smooth G -> 3D combine -> scale fulls -> merge -> error model -> statistics
// for the space group currently set on the experiment, reusing the ingested buffers.
// for_search: the de-novo P1 pass whose merged intensities feed the space-group search - ice-ring
// reflections are dropped from the merge and the error model (kept otherwise, for completeness).
// masked_ice_rings: rings (indices into ICE_RING_RES_A) to drop from the final merge; empty = none.
Result Run(bool for_search, const std::vector<char> &masked_ice_rings = {});
// Override the high-resolution cut for the next Run() - used to gate the de-novo P1 search pass at
// <I/sigma> >= 1 without cutting the final in-symmetry merge. Reset to the manual limit afterwards.
void SetDMinLimit(std::optional<double> d_min_A) { d_min_limit = d_min_A; }
private:
// One integrated observation - a per-frame partial during scaling/combine, or a combined full during
// scale-fulls/merge. Flat (not nested per image); a POD so the arrays translate straight to CUDA.
struct Obs {
int32_t h, k, l;
float I, sigma, d, rlp, partiality, zeta, delta_phi, bkg;
float px = NAN, py = NAN; // predicted detector position (for the absorption surface; CPU path only)
float image_number; // fractional frame position (for 3D-combine contiguity)
int32_t frame; // index of the outcome whose per-frame scale G applies to this obs
uint8_t on_ice;
float corr; // image_scale_corr (working; updated by scaling)
int32_t group; // dense ASU-group id for the current space group; <0 = never mergeable
};
const DiffractionExperiment &x;
std::vector<IntegrationOutcome> &partials_out; // written back at the end of scaling
std::optional<UnitCell> reference_cell;
size_t nthreads;
Logger &logger;
std::string observation_dump_path;
// Fixed settings snapshot (read once in the ctor).
int n_frames = 0;
double min_partiality = 0.02;
std::optional<double> d_min_limit;
bool merge_friedel = true;
double capture_uncertainty_coeff = 0.0;
double min_captured_fraction = 0.0;
double reject_nsigma = 0.0;
bool reject_outliers = false;
double rfree_fraction = 0.0;
int scaling_iter = 3;
bool scale_fulls = true;
bool refine_decay_b = false; // per-time-block Debye-Waller decay correction (radiation damage)
int absorption_iter = 0; // >0: fit a goniometer-frame absorption surface over this many iterations
int modulation_iter = 0; // >0: fit a detector-plane modulation (flat-field) surface, this many iterations
double relative_b_deg = 0.0; // >0: fit a per-batch relative-B (batch width in deg); 0 = off
double mosaicity_deg = 0.1;
float ice_half_width_q = 0.0f;
// Automatic high-resolution cutoff for the written reflections + reported shells (post-merge; the
// scaling, combine and error model always run over the full range). Manual d_min_limit wins.
ResolutionCutoffMethod resolution_cutoff_method = ResolutionCutoffMethod::Off;
double resolution_cc_target = 0.30;
int report_shell_count = 10;
// Flat buffers, allocated once by Ingest() and reused across Run() calls.
std::vector<Obs> partials; // all per-frame partials, grouped by frame
std::vector<int32_t> frame_start, frame_count; // CSR ranges of `partials` per frame
std::vector<uint8_t> frame_cell_ok; // per-frame cell-consistency mask (1 = kept)
std::vector<uint8_t> finite_ok; // per-obs AcceptReflection finiteness (immutable; 1 = kept)
std::vector<double> g_partial; // per-frame partial scale G
// Raw-hkl ordering, built ONCE by Ingest and reused: `perm` lists partial indices sorted by
// (raw h,k,l, image_number); each distinct raw hkl is a contiguous run [rawrun_start, +count) of it.
// The expensive sort happens once here, so per-pass combine (event split) and ASU grouping are linear.
std::vector<int32_t> perm;
std::vector<int32_t> rawrun_start, rawrun_count;
std::vector<int32_t> rawrun_h, rawrun_k, rawrun_l;
std::vector<float> rawrun_d; // representative resolution per raw hkl
std::vector<int32_t> rawrun_group; // dense ASU-group id per raw hkl (<0 = absent/out of range)
std::vector<Obs> fulls; // combined fulls (rebuilt each Run), sorted by frame
std::vector<int32_t> fulls_frame_start, fulls_frame_count; // CSR ranges of `fulls` per frame
std::vector<double> g_full; // per-frame scale on the fulls
// Set by FitPerFrameG: which frames were fitted this call (so corr/G is updated only there).
std::vector<uint8_t> frame_scaled_scratch;
// Per-frame mosaicity smoothed in frame order (deterministic); used to recompute partiality and
// written back for the per-image scaling table. Empty if there is no per-frame mosaicity.
std::vector<float> mos_smooth;
// Radiation-damage monitor (measured by MeasureRadiationDamageB on the scaled fulls before any decay
// correction; report-only, copied into the result statistics by MergeAndStats). NaN / empty until set.
double rad_damage_delta_b = std::numeric_limits<double>::quiet_NaN(); // relative-B first->last (A^2)
std::vector<float> rad_damage_b_batch; // per-batch relative-B curve (A^2)
double rad_damage_batch_deg = 0.0; // rotation width per batch (deg)
// Working per-group arrays (sized to the current group count; reused).
std::vector<int32_t> group_h, group_k, group_l;
#ifdef JFJOCH_USE_CUDA
// GPU engine: the whole hot path (scaling, combine, scale-fulls, per-frame CC, smooth-G, merge +
// error model) runs on the device, resident, when a GPU is present. Null / inactive otherwise, with
// the CPU loops as the bit-parity fallback. Built in Ingest.
std::unique_ptr<RotationScaleMergeGPU> gpu_;
bool gpu_active_ = false;
#endif
// --- helpers (each a flat pass; see the .cpp) ---
// Compute the dense ASU-group id for the current space group by grouping the (pre-sorted) raw-hkl
// runs by their ASU key - one gemmi ASU reduction per distinct raw hkl, not per observation. Fills
// rawrun_group, the group_h/k/l representative tables, and partials[].group; returns the group count.
int ComputeAsuGroups(const HKLKeyGenerator &key_generator);
// Inverse-variance per-group mean of I*corr over `obs` (the merge reference). exclude_ice/masked drop
// those reflections (used for the error-model/merge means, not the scaling reference).
void ReduceGroupMeans(const std::vector<Obs> &obs, int n_groups,
bool exclude_ice, const std::vector<char> &masked_ice_rings,
std::vector<double> &out_mean) const;
// Robust per-frame G fit (IRLS, Cauchy k=3), unity=false uses the rotation partiality, unity=true the
// scale-fulls (partiality already folded in). Reads out_mean[group] as the reference intensity.
void FitPerFrameG(std::vector<Obs> &obs, const std::vector<int32_t> &fstart,
const std::vector<int32_t> &fcount, const std::vector<double> &group_mean_in,
bool unity, std::vector<double> &g);
// corr = rlp / (partiality * G[frame]); leaves corr unchanged for frames that could not be fit.
void UpdateCorr(std::vector<Obs> &obs, const std::vector<double> &g,
const std::vector<uint8_t> &frame_scaled) const;
void SmoothG(std::vector<Obs> &obs, std::vector<double> &g, int window) const;
// The windowed geometric mean of G over frames (the shared first half of SmoothG); the GPU path
// applies the resulting ratio to the resident corr in a kernel instead of the host obs loop.
void ComputeSmoothGWindow(const std::vector<double> &g, int window,
std::vector<double> &g_smooth) const;
// Smooth per-frame mosaicity in frame order and recompute each partial's partiality from it, so the
// per-frame partials of one rocking event tile the curve consistently (they sum toward 1) before the
// 3D combine. Deterministic (frame order); replaces the old arrival-order mosaicity moving average
// that prediction applied. SG-independent, so done once in Ingest.
void SmoothMosaicityAndPartiality();
void Combine(); // partials -> fulls (CPU)
// Post-scale-fulls correction surfaces, each an alternating multiplicative fit of the host fulls' corr
// against the merged reference (cheap host loops; the corrected corr is re-uploaded to the resident
// fulls afterwards). Each is cross-validated (fit even frames, keep only if held-out odd equivalents
// improve) so it is a no-op when its systematic is absent. RefineDecay fits a global Debye-Waller B
// (resolution x time - radiation damage the resolution-flat per-frame G cannot capture; also gated on a
// physical total-dB floor). RefineAbsorption fits a smooth factor over the diffracted-beam direction in
// the goniometer frame (path-length / absorption; negligible at hard X-rays, matters at low energy).
void RefineDecay(int n_groups);
// Solve a smooth per-batch relative-B from the per-batch normal equations (num_c=sum w s^2 y,
// den_c=sum w s^4): data-fidelity + a second-difference (curvature) penalty, by Gauss-Seidel. Returns the
// un-anchored curve; the caller sets the gauge. Shared by the correction and the radiation-damage monitor.
std::vector<double> SolveCurvatureSmoothedB(const std::vector<double> &num,
const std::vector<double> &den) const;
// Fit a smoothed per-batch relative-B curve (A^2 per batch) on the fulls over the ASU-group subset
// {group&1==gparity} (gparity<0 = all): the weighted s^2 slope of ln(Iref/Iobs) per batch against a
// subset-global reference, smoothed and zero-mean-anchored. Drives the per-batch correction.
std::vector<double> FitRelativeBCurve(int n_groups, int n_batch, int frames_per_batch, int gparity) const;
// Radiation-damage MONITOR (report-only): measure the per-batch relative-B on the scaled fulls before
// any decay correction and store the first->last relative-B change + the per-batch curve on this object
// (copied into the result statistics by MergeAndStats, then printed / logged / written to the mmCIF).
void MeasureRadiationDamageB(int n_groups);
// Per-batch relative-B, applied after RefineDecay: the single decay slope removes the average
// radiation-damage falloff, but the relative scattering power drifts NON-monotonically across a run
// (absorption path, crystal slippage, dose bursts). Refine one relative Debye-Waller B per batch
// (FitRelativeBCurve), anchored to zero mean (the constant part is a global Wilson-B, degenerate with
// overall scale). Guarded by a physical peak-to-peak floor and cross-validated by ASU-GROUP parity (a
// per-batch parameter cannot be scored on a held-out FRAME the batch owns; splitting the equivalents
// tests whether a batch's B generalises to reflections it was not fit on). Opt-in (--relative-b).
void RefineRelativeB(int n_groups);
void RefineAbsorption(int n_iter, int n_groups);
// Detector-plane modulation (flat-field): the same cross-validated surface fit as absorption, but the
// cell is the predicted detector position (px, py) instead of the goniometer-frame direction. Corrects
// detector-response / geometric systematics that vary with where a reflection lands; because it lives
// in the detector frame (not tied to the rotation) the same correction concept applies to stills.
void RefineModulation(int n_iter, int n_groups);
// Shared engine for the correction surfaces: given a per-full cell assignment (cell[i] in [0,ncell), or
// <0 to skip), fit a Tikhonov-regularised multiplicative factor per cell against the merged reference,
// cross-validate on even/odd frames, and fold it into corr only if the held-out equivalents improve.
void ApplyCellSurface(const std::vector<int32_t> &cell, int ncell, int n_iter, int n_groups,
const char *name);
// Sort `fulls` by peak frame and (re)build fulls_frame_start/count (the per-frame CSR the scale-fulls
// step slices). Shared by the CPU Combine tail and the GPU combine path.
void SortFullsByFrame();
// Per-frame CC vs the partial merge reference (CPU; the GPU equivalent is gpu_->ComputePartialCC).
void ComputePerFrameCC(const std::vector<double> &partial_group_mean,
std::vector<double> &cc, std::vector<int64_t> &cc_n) const;
// Write G/CC/mosaicity back onto the partials (once, at the end of partial scaling) from the given
// per-frame cc/cc_n, so the offline per-image scaling table is still exported.
void FinalizePerFrameScale(const std::vector<double> &cc, const std::vector<int64_t> &cc_n,
const std::vector<uint8_t> &frame_scaled);
// Error model + merge + statistics over the fulls (the last stage). n_groups is the fulls group count.
// fulls_resident: the (scaled) fulls + their group CSR are still on the GPU, so the em-stats / samples
// / merge-accumulate / R_meas reductions run there (only per-group + samples come back).
Result MergeAndStats(int n_groups, bool for_search, const std::vector<char> &masked_ice_rings,
bool fulls_resident);
};