Files
Jungfraujoch/rugnux/rugnux_cli.cpp
T
leonarski_fandClaude Opus 5 a6be35ccdb Azimuthal integration: optional sigma clipping of the reported profile
The profile is the MEAN of each bin, so a few strong reflections landing in a bin
lift it exactly as a smooth powder ring does. That is the wrong quantity whenever
the profile is wanted as a background rather than as a measurement of what is in
the bin - the ice score being the case in point, where reading a plain profile
INVERTED the metric: over 37 rotation crystals the two highest-scoring crystals
had no ice at all.

The adaptive spot finder already computes the right thing, a sigma-clipped
per-resolution-ring background, as a byproduct of its own threshold. Where it
runs, the ice score uses that. Where it does not - --no-adaptive-spots,
--azint-only, and anything reading the profile the broker wrote - there was no way
to get it. This adds one: azim_int_settings.sigma_clip (rugnux --azim-sigma-clip),
0 = off, minimum 2 because a tighter clip rejects a large part of a clean Gaussian
bin and biases the estimate low rather than removing outliers.

Two clip passes follow the plain one, matching the finder's recipe - the first
pass's standard deviation is itself inflated by the peaks being removed, so one
pass leaves a threshold that is still too generous. A bin with fewer than eight
pixels is left alone: at the detector edge and behind the beam stop there is no
spread to clip on.

Both engines do it. On the GPU the accept range is computed by a small kernel and
stays resident, so a clip pass is one more read of the same pixels and no round
trip; the two accumulation kernels take the range as a pointer that is null on the
plain pass. Measured on a JUNGFRAU rotation dataset, non-adaptive path: azimuthal
integration 0.02 -> 0.06 ms per image, exactly the 3x the extra passes predict,
against a 0.34 ms per-image total.

Note what the result IS: the smooth background under the peaks, not the bin mean.
It should not be switched on where a ring's integrated intensity is wanted - the
powder-ring geometry fit reads ring peaks, and those are what a clip is designed
to remove. Off by default, so nothing changes unless it is asked for.

Not exposed over the REST API - that needs the generated model regenerated, which
is a separate step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 00:18:52 +02:00

1852 lines
104 KiB
C++

// SPDX-FileCopyrightText: 2024 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <csignal>
#include <optional>
#include <getopt.h>
#include <iostream>
#include <limits>
#include <sstream>
#include <string>
#include <thread>
#include <type_traits>
#include <vector>
#include "../reader/JFJochHDF5Reader.h"
#include "../common/Logger.h"
#include "../common/Definitions.h"
#include "../common/DiffractionExperiment.h"
#include "../common/PixelMask.h"
#include "../common/print_license.h"
#include "../image_analysis/LoadFCalcFromMtz.h"
#include "../image_analysis/UpdateReflectionResolution.h"
#include "../image_analysis/WriteReflections.h"
#include "../image_analysis/scale_merge/Merge.h"
#include "../image_analysis/scale_merge/RfreeFlags.h"
#include "../image_analysis/scale_merge/ScaleOnTheFly.h"
#include "../image_analysis/scale_merge/StillsPartialityRefine.h"
#include "../image_analysis/scale_merge/RotationScaleMerge.h"
#include "../image_analysis/scale_merge/ResolutionCutoff.h"
#include "../image_analysis/scale_merge/TwinningAnalysis.h"
#include "../image_analysis/scale_merge/SearchSpaceGroup.h"
#include "Rugnux.h"
#include "RugnuxDefaults.h"
#include "ModelValidation.h"
// Spots kept per image (the strongest ones) and handed to indexing. Offline reprocessing is not
// bound by the online spot budget, so this is rugnux's own default rather than the 250 the
// DatasetSettings constructor uses for the detector. Measured over the 37-crystal rotation battery,
// lifting it to 1000 leaves low-resolution R_meas better or equal on every crystal (16 better, 0
// worse, the rest untouched because their frames never reach the cap), with R_meas 14/4 and ISa 14/6
// in its favour and no measurable cost in wall clock. It is also what jfjoch_viewer already sends.
constexpr int64_t RUGNUX_MAX_SPOT_COUNT = 1000;
// Default rot3d per-frame scale-G smoothing range (XDS DELPHI-like), in degrees of rotation.
constexpr double SMOOTH_G_DEFAULT_DEG = 5.0;
// Default rot3d per-batch relative-B batch width (bare --relative-b), in degrees of rotation.
constexpr double RELATIVE_B_DEFAULT_DEG = 10.0;
void print_usage() {
std::cout << "Usage rugnux {<options>} <input.h5>" << std::endl;
std::cout << "Options:" << std::endl;
std::cout << " -o, --output-prefix <txt> Output file prefix (default: output)" << std::endl;
std::cout << " -N, --threads <num> Number of threads (default: all hardware threads)" << std::endl;
std::cout << " -s, --start-image <num> Start image number (default: 0)" << std::endl;
std::cout << " -e, --end-image <num> End image number (default: all)" << std::endl;
std::cout << " -t, --stride <num> Image stride (default: 1)" << std::endl;
std::cout << " -v, --verbose Verbose output" << std::endl;
std::cout << std::endl;
std::cout << " Modes (default: full analysis - spot finding, indexing, integration and merging)" << std::endl;
std::cout << " --azint-only Only run azimuthal integration (no spot finding/indexing); writes <prefix>_process.h5" << std::endl;
std::cout << " --scale Only re-scale/merge the already-integrated reflections in <input> (no re-integration)" << std::endl;
std::cout << std::endl;
std::cout << " Spot finding" << std::endl;
std::cout << " --spot-sigma <num> Noise sigma level for spot finding (default: 4.0)" << std::endl;
std::cout << " --spot-threshold <num> Photon count threshold for spot finding (default: 10)" << std::endl;
std::cout << " --min-pix-per-spot <num> Minimum connected strong pixels per spot. If omitted, min-pix is chosen PER IMAGE (stills indexing): the frame is indexed at min-pix 3/2/1 and the one maximising indexed count x indexed fraction is kept. Give an explicit value to force a fixed min-pix instead." << std::endl;
std::cout << " --adaptive-spots Self-calibrating detection (DEFAULT): the strong-pixel threshold comes from each image's own per-resolution-ring noise instead of the fixed --spot-threshold, so one setting adapts across datasets (no per-dataset --spot-threshold/--spot-sigma tuning)." << std::endl;
std::cout << " --no-adaptive-spots Turn adaptive detection off and use the fixed --spot-threshold / --spot-sigma finder instead" << std::endl;
std::cout << " --spot-false-pixels <num> Adaptive detection operating point: expected noise pixels tolerated per frame (default: 100; implies --adaptive-spots)" << std::endl;
std::cout << " --spot-high-resolution <num> High resolution limit for spot finding. If omitted (or 0), spot finding is not clipped in resolution and extends as far as the detector reaches" << std::endl;
std::cout << " --spot-low-resolution <num> Low resolution limit for spot finding, in A (default: 50; lower it, e.g. 24, to exclude the direct-beam halo on weakly-diffracting serial data)" << std::endl;
std::cout << " --max-spots <num> Max spot count per image, the strongest ones, handed to indexing (default: 1000)" << std::endl;
std::cout << " --detect-ice-rings[=on|off] Flag ice-ring spots (de-prioritised in indexing) and exclude ice-ring reflections from scaling. Default: the master file's setting, or - where the file says nothing - on for rotation and off for stills" << std::endl;
std::cout << std::endl;
std::cout << " Indexing" << std::endl;
std::cout << " (A dataset with a rotation goniometer axis is processed as rotation data by default; use --force-still to override)" << std::endl;
std::cout << " --force-still Process a rotation (goniometer) dataset as independent stills (still indexing + per-image ScaleOnTheFly) instead of rotation" << std::endl;
std::cout << " -R, --two-pass-rotation[=num] Two-pass offline rotation indexing (default for goniometer data; optional first-pass image count, default: 100)" << std::endl;
std::cout << " --single-pass-rotation[=num] Use online-like single-pass rotation indexing (optional: min angular range deg)" << std::endl;
std::cout << " --force-rotation-lattice <vec> Force rotation indexer with external lattice (in Angstrom) : \"a0x,a0y,a0z,a1x,a1y,a1z,a2x,a2y,a2z\" (9 floats, skips first pass)" << std::endl;
std::cout << " --rotation-no-postrefine Disable the (default-on) two-pass rotation post-refine (post-refine detector distance/beam + cell/axis, then re-integrate; the refined pass is the canonical <prefix>_* output, the header-geometry pass is kept as <prefix>_01_*)" << std::endl;
std::cout << " -X, --indexing-algorithm <txt> Indexing algorithm (FFBIDX|FFT|FFTW|Auto|None)" << std::endl;
std::cout << " -S, --space-group <num|symbol> Space group number (92) or symbol (P43212) - for indexing and scaling" << std::endl;
std::cout << " -C, --unit-cell <cell> Fix reference unit cell: \"a,b,c,alpha,beta,gamma\"" << std::endl;
std::cout << " -r, --refine <txt> Geometry refinement algorithm (none|orientation|beam_and_lattice|flex); flex tries all three per image and keeps whichever indexes the most spots (alias: multi)" << std::endl;
std::cout << " --refine-geometry[=N|off] Stills: extra first pass that bundle-adjusts the shared beam/distance/cell from N strongly-indexed frames (default: 200) then re-indexes (lifts weak-stills indexing). Default ON for stills when a reference cell is given (-C / reference MTZ); =off disables" << std::endl;
std::cout << " --index-ice-rings[=on|off] Index on the spots flagged as sitting on an ice ring too, instead of setting them aside (default: off; no effect without --detect-ice-rings)" << std::endl;
std::cout << std::endl;
std::cout << " Scaling and merging (on by default)" << std::endl;
std::cout << " --no-merge Skip scaling and merging; write only the per-image _process.h5" << std::endl;
std::cout << " --scale-fulls rot3d: after the 3D combine, refit a per-frame scale on the fulls (XDS order, Unity model). Default ON for rot3d" << std::endl;
std::cout << " --no-scale-fulls Disable the rot3d scale-fulls refit (it is on by default for rot3d)" << std::endl;
std::cout << " --write-process-h5 Also write the (large) _process.h5 when merging (default: only .mtz/.cif when merging)" << std::endl;
std::cout << " --smooth-g[=deg] rot3d: smooth per-frame scale G over a deg-degree rotation range (XDS DELPHI-like) before the combine (default: 5 for rot3d; 0 = off)" << std::endl;
std::cout << " --relative-b[=deg] rot3d: fit a per-batch relative-B (beyond the single decay slope) over deg-degree batches; cross-validated (default: 10 deg when bare; off otherwise)" << std::endl;
std::cout << " --no-scaling-corrections rot3d: disable the (default-on) decay + absorption + modulation correction surfaces fitted on the fulls after scale-fulls" << std::endl;
std::cout << " --no-expected-variance-merge stills: disable the default expected-variance merge weighting (which rebuilds each weak observation's signal variance at the reflection mean to de-bias the inverse-variance merge); restores observed-sigma weighting" << std::endl;
std::cout << " -A, --anomalous Anomalous mode (don't merge Friedel pairs)" << std::endl;
std::cout << " --scaling-high-resolution <num> High resolution limit for scaling/merging (manual override; default: no limit)" << std::endl;
std::cout << " --resolution-cutoff <txt> Automatic high-resolution cutoff for the written reflections + reported shells: cc-logistic|off (default: cc-logistic; ignored when --scaling-high-resolution is set)" << std::endl;
std::cout << " --resolution-cc-target <num> CC1/2 target defining the cc-logistic fall-off (default: 0.30)" << std::endl;
std::cout << " --resolution-shells <num> Number of resolution shells in the reported statistics table (default: 10)" << std::endl;
std::cout << " --ice-min-score <num> Ice-presence gate: measured ice score (1 = no ice) a run must reach before ANY ice handling is applied - the flagging and the exclusion from scaling (default: 1.5). The eleven hexagonal bands cover 16-26% of the unique reflections whether or not the crystal has ice, so handling ice on a clean crystal is a pure loss. 0 = no gate (always handle ice)" << std::endl;
std::cout << " --ice-min-spot-ratio <num> Second ice-presence channel: found spots on the hexagonal rings over the same q width of ice-free flanks beside them (1 = spots spread evenly). Ice in large crystallites diffracts as discrete spots and leaves the radial profile flat, so --ice-min-score alone is blind to it. Default 2.0; 0 disables this channel" << std::endl;
std::cout << " --min-partiality <num> Minimum partiality to accept reflection (default: 0.02)" << std::endl;
std::cout << " --capture-uncertainty <num> rot3d: systematic sigma ~num*(1-captured_fraction)*I on under-captured fulls (default: 1.0 for rot3d, 0 otherwise)" << std::endl;
std::cout << " --min-captured-fraction <num> rot3d: drop a combined full whose rocking curve was captured below this fraction (edge-of-sweep truncated fulls) (default: 0.7 for rotation, 0 otherwise; 0 = off)" << std::endl;
std::cout << " --mosaicity <num> Diagnostic: fix the scaling mosaicity (deg) instead of the per-image seed" << std::endl;
std::cout << " --reject-outliers <num> Per-observation merge outlier rejection, N sigma from the per-reflection median (default: 6 for rot3d, XDS/DIALS-style; 0 = off)" << std::endl;
std::cout << " --search-min-zeta <num> De-novo space-group search only: also search a merge of just the observations whose Lorentz geometry |zeta| reaches this, and keep whichever search found MORE symmetry (default: 0.85 for rotation, 0 = single search). Reflections crossing the Ewald sphere near-tangentially are measured worst and can make a real symmetry operator look like a twin law" << std::endl;
std::cout << " --min-image-cc <num> Per-image CC limit in percent (default: no limit)" << std::endl;
std::cout << " --scaling-iterations <num> Number of scaling iterations with no reference data (default: 3)" << std::endl;
std::cout << " -z, --reference-mtz <file> Reference MTZ file" << std::endl;
std::cout << " --reference-column <label> Reference MTZ column to use (default: auto - F-model, else IMEAN/I, else FP/FOBS/F)" << std::endl;
std::cout << " --model <file.pdb> After merging, validate vs this model: R-free + 2Fo-Fc/Fo-Fc maps" << std::endl;
std::cout << std::endl;
std::cout << " Integration" << std::endl;
std::cout << " --bandwidth <num> Relative X-ray bandwidth FWHM (e.g. 0.01 for 1% DMM); default from file or 0" << std::endl;
std::cout << " --integration-radius <r> Signal-box radius r1, or r1,r2,r3 (px). One value => r2=r1+2, r3=r1+4" << std::endl;
std::cout << " --integration-high-resolution <num> High resolution limit for prediction/integration. If omitted (or 0), integration extends as far as the detector reaches" << std::endl;
std::cout << " --max-hkl <n> Predict reflections with |h|,|k|,|l| <= n. Default: derived per crystal from the refined cell (ceil(longest axis / d_min) + 1), which is the exact bound - set it only to override that" << std::endl;
std::cout << " --background-clip <n> Monochromatic (rotation + still): high-side clip of the background ring at mean + n*sqrt(mean) (default 4; 0 = off). This is the default background estimator - it rejects neighbour cores and zingers without the symmetric trim's Poisson skew bias. Broadband data always clip, at 3 sigma; ignored by --integrator boxsum" << std::endl;
std::cout << " --background-radial[=on|off|auto] Correct the background ring for the CURVATURE of the radial background (default auto). The signal disk and the background ring are concentric, so a background linear in position cancels between them and only curvature survives - which on a smooth ice ring reaches +26 counts on a single reflection. Auto applies it per image where that image's ice score shows a smooth powder ring, which is where a radius-only background model holds; on ice made of discrete crystallite spots there is no such ring and the correction makes the bias worse. Costs one short dot product per reflection and no extra pixel reads" << std::endl;
std::cout << " --background-trim <f> Use the old symmetric trimmed mean for the background ring instead of the clip (0<=f<0.5; 0.10 was the former default). Switches --background-clip off. A symmetric trim is biased low on Poisson data and adds ~5 counts to every partial, so this is for back compatibility only; 0 = plain ring mean" << std::endl;
std::cout << " --integrator <txt> Spot integrator boxsum|gaussian|empirical (default: gaussian profile-fit; boxsum is the classical fallback)" << std::endl;
std::cout << " --simple-stills stills: treat every reflection as a full (p=1, single-pass scale/merge); disables the default physical partiality post-refinement" << std::endl;
std::cout << " -q, --azim-q-spacing <num> Azimuthal-integration Q bin spacing (1/A) (default: 0.01)" << std::endl;
std::cout << " --azim-min-q <num> Azimuthal-integration minimum Q (1/A)" << std::endl;
std::cout << " --azim-max-q <num> Azimuthal-integration maximum Q (1/A). If omitted, integration extends to the highest Q the detector reaches." << std::endl;
std::cout << " --azim-phi-bins <num> Number of azimuthal (phi) bins (default: 1)" << std::endl;
std::cout << " --azim-sigma-clip <num> Sigma-clip the azimuthal profile: repeat the integration twice more, each time rejecting pixels further than <num> sigma from their own bin's mean (default: 0 = off, plain per-bin mean; must be >= 2). A powder ring is azimuthally smooth and survives, Bragg peaks do not, so the result is the smooth background UNDER the peaks - do not use it where a ring's integrated intensity is wanted" << std::endl;
std::cout << " --polarization-correction <on|off> Enable/disable azimuthal polarization correction" << std::endl;
std::cout << " --solid-angle-correction <on|off> Enable/disable azimuthal solid angle correction" << std::endl;
std::cout << std::endl;
std::cout << " Geometry overrides (defaults taken from the input file)" << std::endl;
std::cout << " --beam-x <num> Beam center X (pixel)" << std::endl;
std::cout << " --beam-y <num> Beam center Y (pixel)" << std::endl;
std::cout << " --detector-distance <num> Detector distance (mm)" << std::endl;
std::cout << " --wavelength <num> Wavelength (A)" << std::endl;
std::cout << " --rot1 <num> PONI rotation 1 (rad)" << std::endl;
std::cout << " --rot2 <num> PONI rotation 2 (rad)" << std::endl;
std::cout << " --polarization <num> Polarization factor" << std::endl;
}
enum {
OPT_SPOT_SIGMA = 1000,
OPT_SPOT_THRESHOLD,
OPT_MIN_PIX_PER_SPOT,
OPT_ADAPTIVE_SPOTS,
OPT_NO_ADAPTIVE_SPOTS,
OPT_SPOT_FALSE_PIXELS,
OPT_SPOT_RESOLUTION,
OPT_SPOT_LOW_RESOLUTION,
OPT_MAX_SPOTS,
OPT_MIN_PARTIALITY,
OPT_MIN_IMAGE_CC,
OPT_SEARCH_MIN_ZETA,
OPT_SCALING_ITERATIONS,
OPT_SCALING_HIGH_RESOLUTION,
OPT_RESOLUTION_CUTOFF,
OPT_RESOLUTION_CC_TARGET,
OPT_RESOLUTION_SHELLS,
OPT_SINGLE_PASS_ROTATION,
OPT_FORCE_ROTATION_LATTICE,
OPT_ROTATION_NO_POSTREFINE,
OPT_BACKGROUND_CLIP,
OPT_BACKGROUND_RADIAL,
OPT_REFINE_GEOMETRY,
OPT_BANDWIDTH,
OPT_INTEGRATION_RADIUS,
OPT_BACKGROUND_TRIM,
OPT_MAX_HKL,
OPT_INTEGRATION_HIGH_RES,
OPT_REJECT_OUTLIERS,
OPT_REFERENCE_COLUMN,
OPT_MODEL,
OPT_DUMP_OBSERVATIONS,
OPT_INTEGRATOR,
OPT_SIMPLE_STILLS,
OPT_SCALE_FULLS,
OPT_CAPTURE_UNCERTAINTY,
OPT_MIN_CAPTURED_FRACTION,
OPT_MOSAICITY,
OPT_SMOOTH_G,
OPT_RELATIVE_B,
OPT_NO_SCALING_CORRECTIONS,
OPT_NO_EXPECTED_VARIANCE_MERGE,
OPT_DETECT_ICE_RINGS,
OPT_INDEX_ICE_RINGS,
OPT_ICE_MIN_SCORE,
OPT_ICE_MIN_SPOT_RATIO,
OPT_NO_SCALE_FULLS,
OPT_WRITE_PROCESS_H5,
OPT_FORCE_STILL,
OPT_AZIM_MIN_Q,
OPT_AZIM_MAX_Q,
OPT_AZIM_PHI_BINS,
OPT_AZIM_SIGMA_CLIP,
OPT_AZINT_ONLY,
OPT_SCALE,
OPT_NO_MERGE,
OPT_POLARIZATION_CORRECTION,
OPT_SOLID_ANGLE_CORRECTION,
OPT_BEAM_X,
OPT_BEAM_Y,
OPT_DETECTOR_DISTANCE,
OPT_WAVELENGTH,
OPT_ROT1,
OPT_ROT2,
OPT_POLARIZATION
};
static option long_options[] = {
{"verbose", no_argument, nullptr, 'v'},
{"output-prefix", required_argument, nullptr, 'o'},
{"threads", required_argument, nullptr, 'N'},
{"start-image", required_argument, nullptr, 's'},
{"end-image", required_argument, nullptr, 'e'},
{"stride", required_argument, nullptr, 't'},
{"indexing-algorithm", required_argument, nullptr, 'X'},
{"unit-cell", required_argument, nullptr, 'C'},
{"reference-mtz", required_argument, nullptr, 'z'},
{"reference-column", required_argument, nullptr, OPT_REFERENCE_COLUMN},
{"model", required_argument, nullptr, OPT_MODEL},
{"dump-observations", required_argument, nullptr, OPT_DUMP_OBSERVATIONS},
{"space-group", required_argument, nullptr, 'S'},
{"anomalous", no_argument, nullptr, 'A'},
{"azint-only", no_argument, nullptr, OPT_AZINT_ONLY},
{"scale", no_argument, nullptr, OPT_SCALE},
{"no-merge", no_argument, nullptr, OPT_NO_MERGE},
{"scale-fulls", no_argument, nullptr, OPT_SCALE_FULLS},
{"no-scale-fulls", no_argument, nullptr, OPT_NO_SCALE_FULLS},
{"write-process-h5", no_argument, nullptr, OPT_WRITE_PROCESS_H5},
{"smooth-g", optional_argument, nullptr, OPT_SMOOTH_G},
{"relative-b", optional_argument, nullptr, OPT_RELATIVE_B},
{"no-scaling-corrections", no_argument, nullptr, OPT_NO_SCALING_CORRECTIONS},
{"no-expected-variance-merge", no_argument, nullptr, OPT_NO_EXPECTED_VARIANCE_MERGE},
{"refine", required_argument, nullptr, 'r'},
{"two-pass-rotation", optional_argument, nullptr, 'R'},
{"single-pass-rotation", optional_argument, nullptr, OPT_SINGLE_PASS_ROTATION},
{"force-still", no_argument, nullptr, OPT_FORCE_STILL},
{"azim-q-spacing", required_argument, nullptr, 'q'},
{"azim-min-q", required_argument, nullptr, OPT_AZIM_MIN_Q},
{"azim-max-q", required_argument, nullptr, OPT_AZIM_MAX_Q},
{"azim-phi-bins", required_argument, nullptr, OPT_AZIM_PHI_BINS},
{"azim-sigma-clip", required_argument, nullptr, OPT_AZIM_SIGMA_CLIP},
{"polarization-correction", required_argument, nullptr, OPT_POLARIZATION_CORRECTION},
{"solid-angle-correction", required_argument, nullptr, OPT_SOLID_ANGLE_CORRECTION},
{"beam-x", required_argument, nullptr, OPT_BEAM_X},
{"beam-y", required_argument, nullptr, OPT_BEAM_Y},
{"detector-distance", required_argument, nullptr, OPT_DETECTOR_DISTANCE},
{"wavelength", required_argument, nullptr, OPT_WAVELENGTH},
{"rot1", required_argument, nullptr, OPT_ROT1},
{"rot2", required_argument, nullptr, OPT_ROT2},
{"polarization", required_argument, nullptr, OPT_POLARIZATION},
{"force-rotation-lattice", required_argument, nullptr, OPT_FORCE_ROTATION_LATTICE},
{"rotation-no-postrefine", no_argument, nullptr, OPT_ROTATION_NO_POSTREFINE},
{"refine-geometry", optional_argument, nullptr, OPT_REFINE_GEOMETRY},
{"spot-sigma", required_argument, nullptr, OPT_SPOT_SIGMA},
{"spot-threshold", required_argument, nullptr, OPT_SPOT_THRESHOLD},
{"min-pix-per-spot", required_argument, nullptr, OPT_MIN_PIX_PER_SPOT},
{"adaptive-spots", no_argument, nullptr, OPT_ADAPTIVE_SPOTS},
{"no-adaptive-spots", no_argument, nullptr, OPT_NO_ADAPTIVE_SPOTS},
{"spot-false-pixels", required_argument, nullptr, OPT_SPOT_FALSE_PIXELS},
{"spot-high-resolution", required_argument, nullptr, OPT_SPOT_RESOLUTION},
{"spot-low-resolution", required_argument, nullptr, OPT_SPOT_LOW_RESOLUTION},
{"max-spots", required_argument, nullptr, OPT_MAX_SPOTS},
{"min-partiality", required_argument, nullptr, OPT_MIN_PARTIALITY},
{"capture-uncertainty", required_argument, nullptr, OPT_CAPTURE_UNCERTAINTY},
{"min-captured-fraction", required_argument, nullptr, OPT_MIN_CAPTURED_FRACTION},
{"mosaicity", required_argument, nullptr, OPT_MOSAICITY},
{"min-image-cc", required_argument, nullptr, OPT_MIN_IMAGE_CC},
{"search-min-zeta", required_argument, nullptr, OPT_SEARCH_MIN_ZETA},
{"scaling-iterations", required_argument, nullptr, OPT_SCALING_ITERATIONS},
{"scaling-high-resolution", required_argument, nullptr, OPT_SCALING_HIGH_RESOLUTION},
{"background-clip", required_argument, nullptr, OPT_BACKGROUND_CLIP},
{"background-radial", optional_argument, nullptr, OPT_BACKGROUND_RADIAL},
{"resolution-cutoff", required_argument, nullptr, OPT_RESOLUTION_CUTOFF},
{"resolution-cc-target", required_argument, nullptr, OPT_RESOLUTION_CC_TARGET},
{"resolution-shells", required_argument, nullptr, OPT_RESOLUTION_SHELLS},
{"bandwidth", required_argument, nullptr, OPT_BANDWIDTH},
{"integration-radius", required_argument, nullptr, OPT_INTEGRATION_RADIUS},
{"background-trim", required_argument, nullptr, OPT_BACKGROUND_TRIM},
{"max-hkl", required_argument, nullptr, OPT_MAX_HKL},
{"integration-high-resolution", required_argument, nullptr, OPT_INTEGRATION_HIGH_RES},
{"integrator", required_argument, nullptr, OPT_INTEGRATOR},
{"simple-stills", no_argument, nullptr, OPT_SIMPLE_STILLS},
{"detect-ice-rings", optional_argument, nullptr, OPT_DETECT_ICE_RINGS},
{"index-ice-rings", optional_argument, nullptr, OPT_INDEX_ICE_RINGS},
{"ice-min-score", required_argument, nullptr, OPT_ICE_MIN_SCORE},
{"ice-min-spot-ratio", required_argument, nullptr, OPT_ICE_MIN_SPOT_RATIO},
{"reject-outliers", required_argument, nullptr, OPT_REJECT_OUTLIERS},
{nullptr, 0, nullptr, 0}
};
void trim_in_place(std::string &t) {
size_t b = 0;
while (b < t.size() && std::isspace(static_cast<unsigned char>(t[b]))) b++;
size_t e = t.size();
while (e > b && std::isspace(static_cast<unsigned char>(t[e - 1]))) e--;
t = t.substr(b, e - b);
};
bool parse_float_strict(const std::string &t, float &out) {
try {
size_t idx = 0;
out = std::stof(t, &idx);
return idx == t.size();
} catch (...) {
return false;
}
};
// Parse a required numeric option argument, optionally bounded to [min_value, max_value], or print a
// clear error and exit. getopt hands option arguments over as raw C strings; atoi()/atof() silently
// return 0 on non-numeric input (so a typo like "--min-pix-per-spot 2O" becomes 2 or 0) and std::sto*
// throws, which would terminate the program. This rejects non-numeric input, trailing garbage
// ("1.5foo"), integer overflow, and out-of-range values. T may be integral or floating-point; the
// bounds default to the full representable range (i.e. unbounded).
template <typename T>
T parse_number_arg(const char *arg, const char *option_name, Logger &logger,
T min_value = std::numeric_limits<T>::lowest(),
T max_value = std::numeric_limits<T>::max()) {
std::string s = arg ? arg : "";
trim_in_place(s);
T value{};
bool parsed = false;
if (!s.empty()) {
try {
size_t idx = 0;
if constexpr (std::is_integral_v<T>) {
const long long v = std::stoll(s, &idx);
value = static_cast<T>(v);
parsed = (idx == s.size()) && (static_cast<long long>(value) == v); // no overflow
} else {
value = static_cast<T>(std::stod(s, &idx));
parsed = (idx == s.size());
}
} catch (...) {}
}
if (!parsed) {
logger.Error("Invalid numeric value for {}: '{}'", option_name, arg ? arg : "<null>");
print_usage();
exit(EXIT_FAILURE);
}
if (value < min_value || value > max_value) {
logger.Error("Value for {} out of range: {} (expected {} to {})",
option_name, value, min_value, max_value);
print_usage();
exit(EXIT_FAILURE);
}
return value;
}
// Thin unbounded wrappers for the existing floating-point call sites.
double parse_double_arg(const char *arg, const char *option_name, Logger &logger) {
return parse_number_arg<double>(arg, option_name, logger);
}
float parse_float_arg(const char *arg, const char *option_name, Logger &logger) {
return parse_number_arg<float>(arg, option_name, logger);
}
bool parse_on_off(const char *arg, bool &out) {
std::string s = arg ? arg : "";
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (s == "on" || s == "1" || s == "true" || s == "yes") {
out = true;
return true;
}
if (s == "off" || s == "0" || s == "false" || s == "no") {
out = false;
return true;
}
return false;
}
std::optional<UnitCell> parse_unit_cell_arg(const char *arg) {
if (!arg)
return std::nullopt;
std::string s(arg);
trim_in_place(s);
if (s.size() >= 2 && ((s.front() == '"' && s.back() == '"') || (s.front() == '\'' && s.back() == '\''))) {
s = s.substr(1, s.size() - 2);
trim_in_place(s);
}
std::vector<std::string> parts;
parts.reserve(6);
size_t start = 0;
while (true) {
size_t pos = s.find(',', start);
if (pos == std::string::npos) {
parts.push_back(s.substr(start));
break;
}
parts.push_back(s.substr(start, pos - start));
start = pos + 1;
}
if (parts.size() != 6)
return std::nullopt;
UnitCell uc{};
if (!parse_float_strict(parts[0], uc.a)) return std::nullopt;
if (!parse_float_strict(parts[1], uc.b)) return std::nullopt;
if (!parse_float_strict(parts[2], uc.c)) return std::nullopt;
if (!parse_float_strict(parts[3], uc.alpha)) return std::nullopt;
if (!parse_float_strict(parts[4], uc.beta)) return std::nullopt;
if (!parse_float_strict(parts[5], uc.gamma)) return std::nullopt;
return uc;
}
std::optional<CrystalLattice> parse_lattice_arg(const char *arg) {
if (!arg)
return std::nullopt;
std::string s(arg);
trim_in_place(s);
if (s.size() >= 2 && ((s.front() == '"' && s.back() == '"') || (s.front() == '\'' && s.back() == '\''))) {
s = s.substr(1, s.size() - 2);
trim_in_place(s);
}
std::vector<std::string> parts;
parts.reserve(9);
size_t start = 0;
while (true) {
size_t pos = s.find(',', start);
if (pos == std::string::npos) {
parts.push_back(s.substr(start));
break;
}
parts.push_back(s.substr(start, pos - start));
start = pos + 1;
}
if (parts.size() != 9)
return std::nullopt;
std::vector<float> vals(9);
for (int i = 0; i < 9; i++) {
if (!parse_float_strict(parts[i], vals[i]))
return std::nullopt;
}
return CrystalLattice(vals);
}
// Shared offline-output settings for the _process.h5 / reflection writer, used by both the --scale
// path and the full-analysis path (each then sets its own space group and images-per-trigger).
void configure_offline_output(DiffractionExperiment &experiment, const std::string &output_prefix) {
ApplyRugnuxExperimentDefaults(experiment); // analysis policy shared with the viewer
experiment.BitDepthImage(32).Compression(CompressionAlgorithm::BSHUF_LZ4);
// Offline CLI: the operator chose the output path, so allow an absolute -o (the multi-user guard
// that FilePrefix() applies is only for remotely-supplied prefixes in the broker/writer).
experiment.FilePrefixTrusted(output_prefix);
experiment.Mode(DetectorMode::Standard); // full image analysis
experiment.PixelSigned(true);
experiment.OverwriteExistingFiles(true);
experiment.SetFileWriterFormat(FileWriterFormat::NXmxLegacy);
experiment.NumTriggers(1);
}
namespace {
std::atomic<Rugnux *> g_active_process{nullptr};
void handle_sigint(int) {
if (auto *p = g_active_process.load())
p->Cancel();
}
}
// The body of main. Settings setters and the pipeline itself throw JFJochException on input the parser
// cannot reject on its own (a resolution limit of zero, a distance of zero, a polarisation above one,
// an unreadable file), so main wraps this and reports rather than letting the exception terminate the
// process with no diagnostic and exit code 134.
static int RunRugnux(int argc, char **argv) {
for (int i = 0; i < argc; i++) {
std::cout << argv[i] << " ";
}
std::cout << std::endl << std::endl;
RegisterHDF5Filter();
print_license("rugnux");
Logger logger("rugnux");
std::string input_file;
std::string output_prefix = "output";
int nthreads = 0; // 0 = auto: resolved to all hardware threads after parsing (see below)
int start_image = 0;
int end_image = -1; // -1 indicates process until end
int image_stride = 1;
bool verbose = false;
bool azint_only = false; // --azint-only: azimuthal integration only (no spot finding/indexing)
bool scale_only = false; // --scale: re-scale/merge stored reflections only (no re-integration)
bool rotation_indexing = false;
bool force_still = false; // --force-still: process a rotation dataset as stills (indexing + scaling)
bool two_pass_rotation = true;
// Set by any spot-finding option. The two-pass rotation first pass reuses the spots stored in the
// file when it has them, so those options would otherwise not reach the pass that determines the
// lattice - the setting would appear to do nothing at all on rotation data.
bool rotation_postrefine_geometry = true; // default on; --rotation-no-postrefine disables it
int rotation_indexing_image_count = 100;
std::optional<float> rotation_indexing_range;
bool run_scaling = true; // merge is on by default; --no-merge turns it off
std::optional<bool> scale_fulls_arg; // --scale-fulls / --no-scale-fulls; default on for rot3d
bool write_process_h5_flag = false; // --write-process-h5; also write _process.h5 when merging
std::optional<bool> detect_ice_rings; // --detect-ice-rings[=on|off]; unset => use the dataset (file) value
bool index_ice_rings = false; // --index-ice-rings[=on|off]; index on the ice-band spots too
std::optional<double> ice_min_score_arg; // --ice-min-score: ice-presence gate on the measured score
std::optional<double> ice_min_spot_ratio_arg; // --ice-min-spot-ratio: the same gate on the spot channel
std::optional<float> min_q, max_q, q_spacing; // azimuthal integration range / -q spacing (1/A)
std::optional<int32_t> azimuthal_bins; // --azimuthal-bins
std::optional<double> azim_sigma_clip; // --azim-sigma-clip: 0 = off
std::optional<bool> polarization_correction; // --polarization-correction (azimuthal integration)
std::optional<bool> solid_angle_correction; // --solid-angle-correction (azimuthal integration)
// Geometry overrides (default: keep the value stored in the input file)
std::optional<float> beam_x, beam_y, detector_distance_mm, wavelength_A, rot1_rad, rot2_rad, polarization_factor;
std::optional<double> smooth_g_deg_arg; // --smooth-g[=deg]; default 5 deg for rot3d, 0 (off) otherwise
std::optional<double> relative_b_deg_arg; // --relative-b[=deg]; per-batch relative-B width, 0 (off) unless given
bool no_scaling_corrections = false; // --no-scaling-corrections: disable rot3d decay+absorption+modulation surfaces
bool no_expected_variance_merge = false; // --no-expected-variance-merge: restore observed-sigma stills merge weighting
bool anomalous_mode = false;
std::optional<int64_t> space_group_number;
std::optional<UnitCell> fixed_reference_unit_cell;
std::optional<int64_t> max_spot_count_override;
float sigma_spot_finding = 4.0;
int64_t photon_count_threshold_spot_finding = 10;
std::optional<int64_t> min_pix_per_spot; // unset -> adaptive per image; a value -> fixed min-pix
std::optional<bool> adaptive_spots; // unset -> on, for both workflows
float false_pixels_per_frame = 100.0f;
std::string ref_mtz;
std::string ref_column;
std::string model_pdb; // --model: PDB to validate merged intensities against (R-free + maps)
std::string dump_observations; // diagnostic: dump unmerged -P rot3d fulls to this path
double min_partiality = 0.02;
std::optional<double> min_captured_fraction_arg; // explicit --min-captured-fraction; default depends on rotation
std::optional<double> capture_uncertainty_arg; // explicit --capture-uncertainty; default depends on rot3d
std::optional<double> forced_mosaicity_arg; // diagnostic: fix the scaling mosaicity (deg) instead of the per-image seed
double min_image_cc = 0.0;
std::optional<double> search_min_zeta_arg; // --search-min-zeta; rotation default below
int64_t scaling_iter = 3;
std::optional<CrystalLattice> forced_rotation_lattice;
std::optional<double> background_clip_arg; // --background-clip: background-ring high-side sigma clip
bool background_radial_given = false; // --background-radial seen at all (unset => auto)
std::optional<bool> background_radial_arg; // when given: set = force on/off, unset = auto
std::optional<int> refine_geometry; // --refine-geometry[=N]: stills global geometry-refinement pass
bool refine_geometry_disabled = false; // --refine-geometry=off: opt out of the stills default-on
std::optional<float> bandwidth_fwhm; // relative FWHM of dlambda/lambda
IndexingAlgorithmEnum indexing_algorithm = IndexingAlgorithmEnum::Auto;
GeomRefinementAlgorithmEnum refinement_algorithm = GeomRefinementAlgorithmEnum::BeamCenter;
std::optional<float> d_min_spot_finding; // unset -> as far as the detector reaches
float d_max_spot_finding = 0; // 0 = keep the SpotFindingSettings default (50 A)
std::optional<float> d_min_scale_merge;
std::optional<ResolutionCutoffMethod> resolution_cutoff_method; // --resolution-cutoff cc-logistic|off
std::optional<double> resolution_cc_target; // --resolution-cc-target
std::optional<int> report_shell_count; // --resolution-shells
std::optional<std::string> integration_radius_arg; // "r1" or "r1,r2,r3"
std::optional<double> background_trim_arg; // --background-trim: background-ring trimmed-mean fraction
std::optional<int64_t> max_hkl_arg; // --max-hkl: half-width of the predicted hkl box
std::optional<double> integration_d_min_arg; // --integration-high-resolution; unset = detector reach
std::optional<IntegratorMode> integrator_mode; // --integrator boxsum|gaussian|empirical
bool simple_stills_flag = false; // --simple-stills: disable the default stills partiality post-refinement
std::optional<double> outlier_reject_nsigma; // merge per-observation outlier rejection
if (argc == 1) {
print_usage();
exit(EXIT_FAILURE);
}
int opt;
int option_index = 0;
const char *short_opts = "vo:N:s:e:t:R::X:C:z:FAS:r:q:";
while ((opt = getopt_long(argc, argv, short_opts, long_options, &option_index)) != -1) {
switch (opt) {
case 'o':
output_prefix = optarg;
break;
case 'v':
verbose = true;
break;
case 'N':
nthreads = atoi(optarg);
break;
case 's':
start_image = atoi(optarg);
break;
case 'e':
end_image = atoi(optarg);
break;
case 't':
image_stride = atoi(optarg);
break;
case 'R':
if (rotation_indexing) {
logger.Error("Rotation indexing already enabled");
exit(EXIT_FAILURE);
}
rotation_indexing = true;
two_pass_rotation = true;
if (optarg)
rotation_indexing_image_count = atoi(optarg);
break;
case OPT_SINGLE_PASS_ROTATION:
if (rotation_indexing) {
logger.Error("Rotation indexing already enabled");
exit(EXIT_FAILURE);
}
rotation_indexing = true;
two_pass_rotation = false;
if (optarg)
rotation_indexing_range = atof(optarg);
break;
case OPT_ROTATION_NO_POSTREFINE:
rotation_postrefine_geometry = false;
break;
case OPT_REFINE_GEOMETRY: {
if (optarg && std::string(optarg) == "off") {
refine_geometry = std::nullopt;
refine_geometry_disabled = true; // opt out of the stills default-on
break;
}
// Positive frame count fed to the bundle adjust; upper-bounded so refine_frames * 50 in
// RefineStillsGeometry cannot overflow int (1e6 is already far past any real dataset).
refine_geometry = optarg
? parse_number_arg<int>(optarg, "--refine-geometry", logger, 1, 1000000)
: 200;
break;
}
case OPT_FORCE_ROTATION_LATTICE: {
if (rotation_indexing) {
logger.Error("Rotation indexing already enabled");
exit(EXIT_FAILURE);
}
rotation_indexing = true;
auto latt = parse_lattice_arg(optarg);
if (!latt.has_value()) {
logger.Error(
"Invalid rotation lattice. Expected: \"a0x,a0y,a0z,a1x,a1y,a1z,a2x,a2y,a2z\" (9 floats, comma-separated). Got: {}",
optarg ? optarg : "<null>");
print_usage();
exit(EXIT_FAILURE);
}
forced_rotation_lattice = latt;
auto uc = latt->GetUnitCell();
logger.Info(
"Forced rotation lattice set: a={:.3f} b={:.3f} c={:.3f} alpha={:.3f} beta={:.3f} gamma={:.3f}",
uc.a, uc.b, uc.c, uc.alpha, uc.beta, uc.gamma);
break;
}
case 'X': {
std::string alg = optarg ? optarg : "";
std::transform(alg.begin(), alg.end(), alg.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (alg == "ffbidx")
indexing_algorithm = IndexingAlgorithmEnum::FFBIDX;
else if (alg == "fft")
indexing_algorithm = IndexingAlgorithmEnum::FFT;
else if (alg == "fftw")
indexing_algorithm = IndexingAlgorithmEnum::FFTW;
else if (alg == "auto")
indexing_algorithm = IndexingAlgorithmEnum::Auto;
else if (alg == "none")
indexing_algorithm = IndexingAlgorithmEnum::None;
else {
logger.Error("Invalid indexing algorithm: {}", alg);
print_usage();
exit(EXIT_FAILURE);
}
break;
}
case 'r': {
std::string alg = optarg ? optarg : "";
std::transform(alg.begin(), alg.end(), alg.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (alg == "none")
refinement_algorithm = GeomRefinementAlgorithmEnum::None;
else if (alg == "beam_and_lattice")
refinement_algorithm = GeomRefinementAlgorithmEnum::BeamCenter;
else if (alg == "orientation")
refinement_algorithm = GeomRefinementAlgorithmEnum::OrientationOnly;
else if (alg == "flex" || alg == "multi") // "multi" kept as a back-compat alias
refinement_algorithm = GeomRefinementAlgorithmEnum::Flex;
else {
logger.Error("Invalid geom refinement algorithm: {}", alg);
print_usage();
exit(EXIT_FAILURE);
}
break;
}
case 'C': {
auto uc = parse_unit_cell_arg(optarg);
if (!uc.has_value()) {
logger.Error(
"Invalid unit cell. Expected: \"a,b,c,alpha,beta,gamma\" (6 floats, comma-separated, no spaces). Got: {}",
optarg ? optarg : "<null>");
print_usage();
exit(EXIT_FAILURE);
}
fixed_reference_unit_cell = uc;
logger.Info(
"Fixed reference unit cell set: a={:.3f} b={:.3f} c={:.3f} alpha={:.3f} beta={:.3f} gamma={:.3f}",
uc->a, uc->b, uc->c, uc->alpha, uc->beta, uc->gamma);
break;
}
case 'z':
ref_mtz = optarg;
break;
case OPT_REFERENCE_COLUMN:
ref_column = optarg;
break;
case OPT_MODEL:
model_pdb = optarg;
break;
case OPT_DUMP_OBSERVATIONS:
dump_observations = optarg;
break;
case 'F':
indexing_algorithm = IndexingAlgorithmEnum::FFT;
break;
case 'A':
anomalous_mode = true;
break;
case 'S': {
// Accept a space-group number ("92") or a Hermann-Mauguin symbol ("P43212", "P 43 21 2").
char *end = nullptr;
const long as_number = strtol(optarg, &end, 10);
if (end != optarg && *end == '\0') {
space_group_number = as_number;
} else if (const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name(optarg)) {
space_group_number = sg->number;
} else {
logger.Error("Unknown space group '{}' (use a number like 92 or a symbol like P43212)", optarg);
exit(EXIT_FAILURE);
}
break;
}
case OPT_SPOT_SIGMA:
sigma_spot_finding = parse_number_arg<float>(optarg, "--spot-sigma", logger, 1.0f);
logger.Info("Noise threshold level for spot finding set to {:.2f} sigma", sigma_spot_finding);
break;
case OPT_SPOT_THRESHOLD:
photon_count_threshold_spot_finding = parse_number_arg<int64_t>(optarg, "--spot-threshold", logger, 0);
logger.Info("Photon-count threshold level for spot finding set to {:d}",
photon_count_threshold_spot_finding);
break;
case OPT_MIN_PIX_PER_SPOT:
// Giving an explicit min-pix opts out of the per-image adaptive selection.
min_pix_per_spot = parse_number_arg<int64_t>(optarg, "--min-pix-per-spot", logger, 1);
logger.Info("Minimum pixels per spot fixed at {:d} (adaptive per-image min-pix off)", *min_pix_per_spot);
break;
case OPT_ADAPTIVE_SPOTS:
adaptive_spots = true;
logger.Info("Adaptive (self-calibrating) spot detection enabled");
break;
case OPT_NO_ADAPTIVE_SPOTS:
adaptive_spots = false;
logger.Info("Adaptive spot detection off: using the fixed --spot-threshold / --spot-sigma finder");
break;
case OPT_SPOT_FALSE_PIXELS:
false_pixels_per_frame = parse_number_arg<float>(optarg, "--spot-false-pixels", logger, 1.0f);
adaptive_spots = true;
logger.Info("Adaptive spot detection: expected false pixels/frame set to {:.0f}", false_pixels_per_frame);
break;
case OPT_SPOT_LOW_RESOLUTION:
d_max_spot_finding = parse_number_arg<float>(optarg, "--spot-low-resolution", logger, 0.0f);
logger.Info("Low resolution limit for spot finding set to {:.1f} A", d_max_spot_finding);
break;
case OPT_SPOT_RESOLUTION: {
// 0 has always meant "no limit" for this setting; keep that, but express it as the unset
// optional the rest of the code understands. Passing the 0 through instead reached
// ResolutionShells (via the spot plot), which rejects a zero d_min and threw away every image.
const auto d_min = parse_number_arg<float>(optarg, "--spot-high-resolution", logger, 0.0f);
if (d_min > 0.0f) {
d_min_spot_finding = d_min;
logger.Info("High resolution limit for spot finding set to {:.2f} A", d_min);
} else {
d_min_spot_finding.reset();
logger.Info("No high resolution limit for spot finding: as far as the detector reaches");
}
break;
}
case OPT_MAX_SPOTS:
max_spot_count_override = parse_number_arg<int64_t>(optarg, "--max-spots", logger, 1);
break;
case OPT_AZINT_ONLY:
azint_only = true;
break;
case OPT_SCALE:
scale_only = true;
break;
case OPT_NO_MERGE:
run_scaling = false;
break;
case OPT_SCALE_FULLS:
scale_fulls_arg = true;
break;
case OPT_NO_SCALE_FULLS:
scale_fulls_arg = false;
break;
case OPT_DETECT_ICE_RINGS:
if (optarg == nullptr || strcmp(optarg, "on") == 0)
detect_ice_rings = true;
else if (strcmp(optarg, "off") == 0)
detect_ice_rings = false;
else {
logger.Error("Invalid --detect-ice-rings value: {} (expected on|off)", optarg);
print_usage();
exit(EXIT_FAILURE);
}
break;
case OPT_INDEX_ICE_RINGS:
if (optarg == nullptr || strcmp(optarg, "on") == 0)
index_ice_rings = true;
else if (strcmp(optarg, "off") == 0)
index_ice_rings = false;
else {
logger.Error("Invalid --index-ice-rings value: {} (expected on|off)", optarg);
print_usage();
exit(EXIT_FAILURE);
}
break;
case OPT_ICE_MIN_SCORE:
ice_min_score_arg = parse_double_arg(optarg, "--ice-min-score", logger);
break;
case OPT_ICE_MIN_SPOT_RATIO:
ice_min_spot_ratio_arg = parse_double_arg(optarg, "--ice-min-spot-ratio", logger);
break;
case OPT_WRITE_PROCESS_H5:
write_process_h5_flag = true;
break;
case OPT_SMOOTH_G:
smooth_g_deg_arg = optarg ? parse_double_arg(optarg, "--smooth-g", logger) : SMOOTH_G_DEFAULT_DEG;
break;
case OPT_RELATIVE_B:
relative_b_deg_arg = optarg ? parse_double_arg(optarg, "--relative-b", logger) : RELATIVE_B_DEFAULT_DEG;
break;
case OPT_NO_EXPECTED_VARIANCE_MERGE:
no_expected_variance_merge = true;
break;
case OPT_NO_SCALING_CORRECTIONS:
no_scaling_corrections = true;
break;
case OPT_MIN_PARTIALITY:
min_partiality = parse_double_arg(optarg, "--min-partiality", logger);
break;
case OPT_CAPTURE_UNCERTAINTY:
capture_uncertainty_arg = parse_double_arg(optarg, "--capture-uncertainty", logger);
break;
case OPT_MIN_CAPTURED_FRACTION:
min_captured_fraction_arg = parse_double_arg(optarg, "--min-captured-fraction", logger);
break;
case OPT_MOSAICITY:
forced_mosaicity_arg = parse_double_arg(optarg, "--mosaicity", logger);
break;
case OPT_INTEGRATION_RADIUS:
integration_radius_arg = optarg;
break;
case OPT_BACKGROUND_TRIM:
background_trim_arg = parse_double_arg(optarg, "--background-trim", logger);
break;
case OPT_MAX_HKL:
max_hkl_arg = parse_number_arg<int64_t>(optarg, "--max-hkl", logger, 1, 511);
break;
case OPT_INTEGRATION_HIGH_RES:
integration_d_min_arg = parse_double_arg(optarg, "--integration-high-resolution", logger);
break;
case OPT_BACKGROUND_RADIAL:
background_radial_given = true;
if (optarg == nullptr || strcmp(optarg, "on") == 0)
background_radial_arg = true;
else if (strcmp(optarg, "off") == 0)
background_radial_arg = false;
else if (strcmp(optarg, "auto") == 0)
background_radial_arg = std::nullopt;
else {
logger.Error("Invalid --background-radial value: {} (expected on|off|auto)", optarg);
return 1;
}
break;
case OPT_BACKGROUND_CLIP:
background_clip_arg = parse_double_arg(optarg, "--background-clip", logger);
break;
case OPT_INTEGRATOR:
if (strcmp(optarg, "boxsum") == 0) integrator_mode = IntegratorMode::BoxSum;
else if (strcmp(optarg, "gaussian") == 0) integrator_mode = IntegratorMode::ProfileGaussian;
else if (strcmp(optarg, "empirical") == 0) integrator_mode = IntegratorMode::ProfileEmpirical;
else { logger.Error("--integrator expects boxsum|gaussian|empirical"); return 1; }
break;
case OPT_SIMPLE_STILLS:
simple_stills_flag = true;
break;
case OPT_REJECT_OUTLIERS:
outlier_reject_nsigma = parse_double_arg(optarg, "--reject-outliers", logger);
break;
case OPT_MIN_IMAGE_CC:
min_image_cc = parse_double_arg(optarg, "--min-image-cc", logger);
break;
case OPT_SEARCH_MIN_ZETA:
search_min_zeta_arg = parse_double_arg(optarg, "--search-min-zeta", logger);
break;
case OPT_SCALING_HIGH_RESOLUTION:
d_min_scale_merge = parse_number_arg<float>(optarg, "--scaling-high-resolution", logger,
0.1f, 1000.0f);
break;
case OPT_RESOLUTION_CUTOFF:
if (strcmp(optarg, "cc-logistic") == 0)
resolution_cutoff_method = ResolutionCutoffMethod::CCHalfLogistic;
else if (strcmp(optarg, "off") == 0)
resolution_cutoff_method = ResolutionCutoffMethod::Off;
else {
logger.Error("Invalid --resolution-cutoff value: {} (expected cc-logistic|off)", optarg);
print_usage();
exit(EXIT_FAILURE);
}
break;
case OPT_RESOLUTION_CC_TARGET:
resolution_cc_target = parse_double_arg(optarg, "--resolution-cc-target", logger);
break;
case OPT_RESOLUTION_SHELLS:
report_shell_count = atoi(optarg);
if (report_shell_count.value() < 1) {
logger.Error("Invalid --resolution-shells value: {} (must be >= 1)", report_shell_count.value());
exit(EXIT_FAILURE);
}
break;
case OPT_FORCE_STILL:
force_still = true;
break;
case 'q':
q_spacing = atof(optarg);
break;
case OPT_AZIM_MIN_Q:
min_q = atof(optarg);
break;
case OPT_AZIM_MAX_Q:
max_q = atof(optarg);
break;
case OPT_AZIM_SIGMA_CLIP:
azim_sigma_clip = parse_double_arg(optarg, "--azim-sigma-clip", logger);
break;
case OPT_AZIM_PHI_BINS:
azimuthal_bins = atoi(optarg);
break;
case OPT_POLARIZATION_CORRECTION: {
bool value;
if (!parse_on_off(optarg, value)) {
logger.Error("Invalid polarization correction value (expected on|off): {}", optarg);
exit(EXIT_FAILURE);
}
polarization_correction = value;
break;
}
case OPT_SOLID_ANGLE_CORRECTION: {
bool value;
if (!parse_on_off(optarg, value)) {
logger.Error("Invalid solid angle correction value (expected on|off): {}", optarg);
exit(EXIT_FAILURE);
}
solid_angle_correction = value;
break;
}
case OPT_BEAM_X: beam_x = parse_float_arg(optarg, "--beam-x", logger); break;
case OPT_BEAM_Y: beam_y = parse_float_arg(optarg, "--beam-y", logger); break;
case OPT_DETECTOR_DISTANCE: detector_distance_mm = parse_float_arg(optarg, "--detector-distance", logger); break;
case OPT_WAVELENGTH: {
// Guard > 0: wavelength is used as a divisor (WVL_1A_IN_KEV / wavelength) below, and a 0
// would produce a non-finite incident energy that throws unguarded and aborts the process.
float w = parse_float_arg(optarg, "--wavelength", logger);
if (!(w > 0.0f)) {
logger.Error("Invalid wavelength (must be > 0 A): {}", optarg);
exit(EXIT_FAILURE);
}
wavelength_A = w;
break;
}
case OPT_ROT1: rot1_rad = parse_float_arg(optarg, "--rot1", logger); break;
case OPT_ROT2: rot2_rad = parse_float_arg(optarg, "--rot2", logger); break;
case OPT_POLARIZATION: polarization_factor = parse_float_arg(optarg, "--polarization", logger); break;
case OPT_SCALING_ITERATIONS:
scaling_iter = atoi(optarg);
if (scaling_iter <= 0) {
logger.Error("Invalid scaling iteration count: {}", scaling_iter);
exit(EXIT_FAILURE);
}
break;
case OPT_BANDWIDTH:
bandwidth_fwhm = atof(optarg);
if (!(bandwidth_fwhm.value() >= 0.0f)) {
logger.Error("Invalid bandwidth: {}", optarg);
exit(EXIT_FAILURE);
}
break;
default:
print_usage();
exit(EXIT_FAILURE);
}
}
if (optind != argc - 1) {
logger.Error("Input file not specified");
print_usage();
exit(EXIT_FAILURE);
}
input_file = argv[optind];
logger.Verbose(verbose);
// -N defaults to 0 = "use all hardware threads"; resolve it to a concrete count here so every mode
// behaves the same. The scale/merge engines expand 0 on their own, but the per-image processing
// loop (Rugnux) spawns exactly nthreads workers, so passing 0 there would spawn none and process
// nothing - hence resolving it centrally rather than relying on each consumer.
if (nthreads <= 0) {
unsigned int hw = std::thread::hardware_concurrency();
nthreads = hw > 0 ? static_cast<int>(hw) : 1;
}
if (azint_only && scale_only) {
logger.Error("--azint-only and --scale are mutually exclusive");
exit(EXIT_FAILURE);
}
// Validate space group number early
const gemmi::SpaceGroup *space_group = nullptr;
if (space_group_number.has_value()) {
space_group = gemmi::find_spacegroup_by_number(space_group_number.value());
if (!space_group) {
logger.Error("Unknown space group number {}", space_group_number.value());
exit(EXIT_FAILURE);
}
logger.Info("Using space group {} (number {})", space_group->hm, space_group_number.value());
}
// 1. Read Input File
JFJochHDF5Reader reader;
try {
reader.ReadFile(input_file);
} catch (const std::exception &e) {
logger.Error("Error reading input file: {}", e.what());
exit(EXIT_FAILURE);
}
const auto dataset = reader.GetDataset();
if (!dataset) {
logger.Error("No experiment dataset found in the input file");
exit(EXIT_FAILURE);
}
if (rotation_indexing_image_count <= 0) {
logger.Error("Invalid number of rotation indexing images: {}", rotation_indexing_image_count);
exit(EXIT_FAILURE);
}
logger.Info("Loaded dataset from {}", input_file);
std::vector<MergedReflection> reference_data;
bool reference_has_free_flags = false;
if (!ref_mtz.empty()) {
try {
const auto reference = LoadReferenceMtz(
ref_mtz, ref_column.empty() ? std::nullopt : std::optional<std::string>(ref_column));
reference_data = reference.reflections;
reference_has_free_flags = reference.has_free_flags;
logger.Info("Loaded {} reference reflections from {} (column {}{}{})",
reference_data.size(), ref_mtz, reference.used_column,
reference.squared ? ", squared to intensity" : "",
reference.default_column ? ", auto-selected" : ", user-specified");
if (reference.has_free_flags)
logger.Info("Reference carries R-free flags (column {}): {} of {} free; the merged "
"reflections will inherit this test set",
reference.free_column, reference.n_free, reference_data.size());
if (reference.d_max > 0.0)
logger.Info("Reference resolution range {:.2f} - {:.2f} A", reference.d_max, reference.d_min);
if (reference.cell.has_value())
logger.Info("Reference unit cell: a={:.3f} b={:.3f} c={:.3f} alpha={:.2f} beta={:.2f} gamma={:.2f}",
reference.cell->a, reference.cell->b, reference.cell->c,
reference.cell->alpha, reference.cell->beta, reference.cell->gamma);
if (!reference.space_group_name.empty())
logger.Info("Reference space group: {} (number {})",
reference.space_group_name, reference.space_group_number.value_or(0));
// Check the reference against the cell that will actually drive the merge. --scale merges
// in the cell stored in the input file (as the former jfjoch_scale did); the -C override
// only takes effect on the full-analysis path, which otherwise determines its cell later by
// indexing (unknown here, so nothing can be checked yet).
const std::optional<UnitCell> data_cell =
scale_only ? dataset->experiment.GetUnitCell() : fixed_reference_unit_cell;
const auto warning = ReferenceConsistencyWarning(
reference, data_cell,
space_group_number.has_value() ? std::optional<int>(static_cast<int>(*space_group_number))
: std::nullopt);
if (!warning.empty())
logger.Warning("{}", warning);
// A reference MTZ fixes the space group and unit cell, unless -S / -C override them.
// (-S with the wrong enantiomorph, or -C with a different cell, is allowed - the explicit
// flag always wins.) The cell is a soft reference: indexing may drift within tolerance.
if (!space_group_number.has_value() && reference.space_group_number.has_value()) {
space_group_number = static_cast<int64_t>(*reference.space_group_number);
logger.Info("Fixing space group from reference MTZ: {} ({})",
reference.space_group_name, *space_group_number);
}
if (!fixed_reference_unit_cell.has_value() && reference.cell.has_value()) {
fixed_reference_unit_cell = reference.cell;
logger.Info("Fixing reference unit cell from reference MTZ (indexing may drift within tolerance)");
}
} catch (const std::exception &e) {
logger.Error("Error reading reference MTZ {}: {}", ref_mtz, e.what());
exit(EXIT_FAILURE);
}
}
// --scale: re-scale and merge the already-integrated reflections stored in the input file,
// without re-running spot finding or integration (folded in from the former rugnux_scale tool).
if (scale_only) {
const auto total_images = static_cast<int>(reader.GetNumberOfImages());
const int last_image = (end_image < 0 || end_image >= total_images) ? total_images - 1 : end_image;
auto reflections = reader.ReadReflections(start_image, last_image);
DiffractionExperiment experiment(dataset->experiment);
configure_offline_output(experiment, output_prefix);
// The reflections in the file are already indexed, so the cell and space group they were
// integrated in are the file's to supply here - but an explicit -S / -C still wins.
if (space_group_number.has_value())
experiment.SpaceGroupNumber(space_group_number);
if (fixed_reference_unit_cell.has_value())
experiment.SetUnitCell(fixed_reference_unit_cell);
// A rotation (goniometer) dataset uses RotationScaleMerge unless --force-still asks for stills scaling.
IndexingSettings indexing_settings;
indexing_settings.RotationIndexing(experiment.GetGoniometer().has_value() && !force_still);
// --detect-ice-rings, applied here as well as on the full path below: this block returns
// before that one runs, so without it the flag is silently ignored by --scale. Same
// precedence as there - command line, then the file, then the geometry's default.
if (detect_ice_rings.has_value())
experiment.DetectIceRings(detect_ice_rings.value());
else if (!dataset->file_detect_ice_rings.has_value())
experiment.DetectIceRings(indexing_settings.GetRotationIndexing());
experiment.ImportIndexingSettings(indexing_settings);
// Start from the same defaults the full pipeline uses, so --scale reproduces the merge that wrote
// the _process.h5 rather than a weaker model of its own. Every CLI override below then applies on
// top, exactly as in the full-analysis path.
const bool rot = experiment.GetGoniometer().has_value() && !force_still;
ScalingSettings scaling_settings = RugnuxDefaultScalingSettings(rot);
if (d_min_scale_merge)
scaling_settings.HighResolutionLimit_A(d_min_scale_merge.value());
if (resolution_cutoff_method) scaling_settings.ResolutionCutoff(*resolution_cutoff_method);
if (resolution_cc_target) scaling_settings.ResolutionCCTarget(*resolution_cc_target);
if (report_shell_count) scaling_settings.ReportShellCount(*report_shell_count);
scaling_settings.MergeFriedel(!anomalous_mode);
scaling_settings.MinPartiality(min_partiality);
scaling_settings.MinCapturedFraction(
min_captured_fraction_arg.value_or(scaling_settings.GetMinCapturedFraction()));
scaling_settings.CaptureUncertaintyCoeff(
capture_uncertainty_arg.value_or(scaling_settings.GetCaptureUncertaintyCoeff()));
scaling_settings.ForcedMosaicity(forced_mosaicity_arg);
scaling_settings.MinCCForImage(min_image_cc / 100.0); // --min-image-cc is percent; the setting is a fraction
scaling_settings.StillsPartialityRefine(!simple_stills_flag);
scaling_settings.ExpectedVarianceMerge(!no_expected_variance_merge);
if (ice_min_score_arg)
scaling_settings.IceMinScore(static_cast<float>(*ice_min_score_arg));
if (ice_min_spot_ratio_arg)
scaling_settings.IceMinSpotRatio(static_cast<float>(*ice_min_spot_ratio_arg));
scaling_settings.OutlierRejectNsigma(
outlier_reject_nsigma.value_or(scaling_settings.GetOutlierRejectNsigma()));
scaling_settings.ScaleFulls(scale_fulls_arg.value_or(scaling_settings.GetScaleFulls()));
scaling_settings.SmoothGDegrees(smooth_g_deg_arg.value_or(scaling_settings.GetSmoothGDegrees()));
scaling_settings.RelativeBDegrees(relative_b_deg_arg.value_or(0.0)); // opt-in only; default off
if (no_scaling_corrections)
scaling_settings.CorrectionSurfaces(false);
experiment.ImportScalingSettings(scaling_settings);
if (!experiment.GetUnitCell()) {
logger.Error("Experiment unit cell not found, cannot update reflection resolution");
exit(EXIT_FAILURE);
}
auto refl_stats = UpdateReflectionResolution(experiment.GetUnitCell().value(), reflections);
logger.Info("Read {} reflections from {} images", refl_stats.n_reflections, refl_stats.n_images);
experiment.ImagesPerTrigger(refl_stats.n_images);
// Ice-ring handling, as the full pipeline does it (Rugnux.cpp): flag reflections on a
// hexagonal-ice powder ring so scaling skips them while the merge keeps them. The flag is not
// stored per reflection, so it has to be recomputed here from the resolution just assigned -
// otherwise --scale re-scales a dataset the writing run had scaled without those reflections,
// and the per-image scales come out of a different fit than the ones in the file.
const float ice_width = SpotFindingSettings().ice_ring_width_Q_recipA;
// ...and gated the same way, on the per-image ice score the writing run stored in the file, so
// --scale reaches the same verdict on the same data as the pipeline that produced it.
double ice_sum = 0.0;
size_t ice_n = 0;
for (const float s : dataset->ice_ring_score)
if (std::isfinite(s)) {
ice_sum += s;
++ice_n;
}
const float ice_min_score = experiment.GetScalingSettings().GetIceMinScore();
// ...and the spot channel, pooled over the run exactly as the full pipeline pools it.
double ring_sum = 0.0, ctrl_sum = 0.0;
for (const float v : dataset->spot_count_ice_rings)
if (std::isfinite(v)) ring_sum += v;
for (const float v : dataset->spot_count_ice_control)
if (std::isfinite(v)) ctrl_sum += v;
// Empty control + spots on the rings = the strongest ice evidence, not its absence.
const double ice_spot_ratio = ctrl_sum > 0.0 ? ring_sum / ctrl_sum : (ring_sum > 0.0 ? 1.0e3 : 0.0);
const float ice_min_spot_ratio = experiment.GetScalingSettings().GetIceMinSpotRatio();
const bool ice_present = (ice_n == 0 || ice_sum / static_cast<double>(ice_n) >= ice_min_score)
|| (ice_min_spot_ratio > 0.0f && ice_spot_ratio >= ice_min_spot_ratio);
if (experiment.IsDetectIceRings() && !ice_present) {
logger.Info("Ice-ring handling: measured ice score {:.2f} and spot ratio {:.2f} below the "
"gates ({:.2f} / {:.2f}), no ice detected - ice-ring handling skipped entirely",
ice_sum / static_cast<double>(ice_n), ice_spot_ratio, ice_min_score,
ice_min_spot_ratio);
} else if (experiment.IsDetectIceRings()) {
size_t total = 0, flagged = 0;
for (auto &outcome : reflections) {
for (auto &r : outcome.reflections) {
++total;
r.on_ice_ring = IsOnIceRing(r.d, ice_width);
if (r.on_ice_ring)
++flagged;
}
}
logger.Info("Ice-ring handling: flagged {} of {} reflections on ice rings (half-width {:.3f} A^-1); "
"excluded from scaling, kept for merging", flagged, total, ice_width);
}
const auto scale_start = std::chrono::steady_clock::now();
std::vector<MergedReflection> merged_reflections;
MergeStatistics merged_statistics;
double error_model_isa = 0.0;
// Rotation (rot3d): the dedicated RotationScaleMerge does the whole self-scale -> 3D combine ->
// merge, including the default-on decay + absorption correction surfaces. It does not support
// external-reference scaling or wedge refinement.
// Everything else (stills, reference scaling) uses ScaleOnTheFly + MergeOnTheFly.
const bool is_rotation = experiment.IsRotationIndexing();
if (is_rotation) {
if (!reference_data.empty()
|| experiment.GetRefineRotationWedgeInScaling()
|| experiment.GetScalingSettings().GetRotationWedgeForScaling().has_value())
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"Rotation scaling/merging (RotationScaleMerge) does not support reference "
"scaling or wedge refinement");
RotationScaleMerge rsm(experiment, reflections, experiment.GetUnitCell(),
scaling_iter, nthreads, logger);
rsm.Ingest();
auto r = rsm.Run(false);
merged_reflections = std::move(r.merged);
merged_statistics = std::move(r.statistics);
error_model_isa = r.isa;
} else {
// Scaling self-references: the reference MTZ (if any) fixes the cell/space group, reports
// CCref and provides the R-free test set, but is NOT a scale anchor - scaling each image
// against a foreign dataset injects cross-dataset systematics and is a worse reference than
// the data's own merge. The per-image scale G is the exact one-pass solution, so one pass
// (iterating a self-rebuilt reference only re-fits the freshly-scaled noise on weak stills).
ScaleOnTheFly(experiment, MergeAll(experiment, reflections)).Scale(reflections, nthreads);
// Physical partiality post-refinement (default on; --simple-stills disables): refine a per-crystal
// orientation tilt against the merge and recompute each reflection's partiality + scale correction
// (no re-integration), then merge with the improved corrections.
if (experiment.GetScalingSettings().GetStillsPartialityRefine()) {
StillsPartialityRefine refiner(experiment);
const double mean_tilt = refiner.Run(reflections, nthreads);
logger.Info("Stills partiality post-refine: mean |dpsi| = {:.3f} deg", mean_tilt);
}
MergeOnTheFly merge_engine(experiment);
merge_engine.ReferenceCell(experiment.GetUnitCell());
// --min-image-cc has to hold for the merge itself, not only for the reported statistics.
merge_engine.FilterByImageCC(experiment.GetScalingSettings().GetMinCCForImage() > 0.0);
// Fit the (a, b) error model from symmetry-mate scatter before merging, exactly as the full
// pipeline does (Rugnux.cpp). Without this the offline --scale merge would use the identity
// model and produce much worse stills intensities (no (b*I)^2 systematic term, no sigma floor).
merge_engine.RefineErrorModel(reflections);
if (merge_engine.ErrorModelActive())
logger.Info("Error model: a={:.3f} b={:.3f} ISa={:.1f} chi2={:.2f}", merge_engine.ErrorModelA(),
merge_engine.ErrorModelB(),
merge_engine.ErrorModelB() > 0 ? 1.0 / merge_engine.ErrorModelB() : 0.0,
merge_engine.ErrorModelChi2());
for (size_t i = 0; i < reflections.size(); ++i)
merge_engine.AddImage(reflections[i], static_cast<int64_t>(i));
merged_reflections = merge_engine.ExportReflections();
// Automatic high-resolution cutoff (post-merge), matching the full-analysis path: a manual
// --scaling-high-resolution wins, otherwise trim the written reflections + reported shells
// to the CC1/2 fall-off. (Rotation is cut inside RotationScaleMerge above.)
const auto &cut_ss = experiment.GetScalingSettings();
// The offline --scale path re-scales a stored _process.h5 and is never a P1 search merge.
const std::optional<double> effective_d_min = ApplyResolutionCutoff(
merged_reflections, cut_ss.GetHighResolutionLimit_A(), cut_ss.GetResolutionCutoff(),
cut_ss.GetResolutionCCTarget(), /*for_search=*/false, logger);
merged_statistics = merge_engine.MergeStats(merged_reflections, reflections, reference_data,
effective_d_min);
error_model_isa = merge_engine.ErrorModelB() > 0 ? 1.0 / merge_engine.ErrorModelB() : 0.0;
}
logger.Info("Scale + merge completed in {:.2f} s ({} unique reflections)",
std::chrono::duration<double>(std::chrono::steady_clock::now() - scale_start).count(),
merged_reflections.size());
// Inherit the campaign's shared R-free test set from the reference MTZ (overriding the
// per-hkl hash the merge assigned), so every dataset flags the same free reflections.
if (reference_has_free_flags && !reference_data.empty() && !merged_reflections.empty()) {
const auto sg = experiment.GetSpaceGroupNumber().value_or(1);
const size_t matched = ApplyReferenceFreeFlags(merged_reflections, static_cast<int32_t>(sg),
reference_data);
logger.Info("R-free flags: inherited the reference test set ({} of {} merged reflections matched)",
matched, merged_reflections.size());
}
std::cout << merged_statistics;
// Space-group determination lives in the full rugnux pipeline; --scale only consumes a space
// group (from the file or -S) and merges in it.
const bool fixed_space_group = space_group || experiment.GetGemmiSpaceGroup().has_value();
if (!fixed_space_group)
logger.Warning("No space group in the input file or on the command line - merged in P1. "
"Re-run rugnux (which determines and stores the space group) or pass "
"-S to scale and merge in the correct symmetry.");
const auto twin_sg_number = experiment.GetSpaceGroupNumber();
const gemmi::SpaceGroup *twin_sg = twin_sg_number
? gemmi::find_spacegroup_by_number(twin_sg_number.value()) : nullptr;
const auto twinning = AnalyzeTwinning(merged_reflections, twin_sg);
std::cout << std::endl << TwinningAnalysisToText(twinning) << std::endl;
if (!output_prefix.empty())
WriteReflections(merged_reflections, *experiment.GetUnitCell(), experiment, merged_statistics,
error_model_isa > 0 ? fmt::format("{:.2f}", error_model_isa) : "?",
twinning, output_prefix);
if (!output_prefix.empty() && !model_pdb.empty()) {
const auto data_sg = experiment.GetSpaceGroupNumber();
// With a reference MTZ the merohedral indexing was already resolved (stills per-image
// scaling); only probe indexing by R-free when model-only, with no reference.
ValidateAgainstModel(merged_reflections, *experiment.GetUnitCell(), model_pdb,
output_prefix, logger,
data_sg ? std::optional<int>(static_cast<int>(*data_sg)) : std::nullopt,
/*probe_indexing_ambiguity=*/reference_data.empty());
}
return 0;
}
uint64_t total_images_in_file = reader.GetNumberOfImages();
if (end_image < 0 || end_image > total_images_in_file)
end_image = total_images_in_file;
if (image_stride < 0) {
logger.Error("Image stride cannot be negative");
exit(EXIT_FAILURE);
}
if (image_stride == 0) {
logger.Error("Image stride cannot be zero");
exit(EXIT_FAILURE);
}
int images_to_process = (end_image - start_image) / image_stride;
if (images_to_process <= 0) {
logger.Warning("No images to process (Start: {}, End: {} Stride: {}, Total: {})", start_image, end_image,
image_stride, total_images_in_file);
return 0;
}
// 2. Setup Experiment & Components
DiffractionExperiment experiment(dataset->experiment);
// Geometry overrides (default: keep the value stored in the input file). Applied before the
// azimuthal-integration settings are derived, which depend on the geometry.
if (beam_x) experiment.BeamX_pxl(beam_x.value());
if (beam_y) experiment.BeamY_pxl(beam_y.value());
if (detector_distance_mm) experiment.DetectorDistance_mm(detector_distance_mm.value());
if (wavelength_A) experiment.IncidentEnergy_keV(WVL_1A_IN_KEV / wavelength_A.value());
if (rot1_rad) experiment.PoniRot1_rad(rot1_rad.value());
if (rot2_rad) experiment.PoniRot2_rad(rot2_rad.value());
// --polarization is applied after configure_offline_output below, which sets the rugnux default.
// Azimuthal integration (default q-spacing 0.01 1/A, from AzimuthalIntegrationSettings): the profile
// resolves the narrow ice rings for the ice-ring score. Shared by --azint-only and full analysis.
// -q / --azim-* / correction flags override; defaults come from the input file.
{
AzimuthalIntegrationSettings azint_settings = experiment.GetAzimuthalIntegrationSettings();
if (min_q || max_q)
azint_settings.QRange_recipA(min_q.value_or(azint_settings.GetLowQ_recipA()),
max_q ? max_q : azint_settings.GetRequestedHighQ_recipA());
if (q_spacing)
azint_settings.QSpacing_recipA(q_spacing.value());
if (azimuthal_bins)
azint_settings.AzimuthalBinCount(azimuthal_bins.value());
if (azim_sigma_clip)
azint_settings.SigmaClip(static_cast<float>(azim_sigma_clip.value()));
if (polarization_correction)
azint_settings.PolarizationCorrection(polarization_correction.value());
if (solid_angle_correction)
azint_settings.SolidAngleCorrection(solid_angle_correction.value());
experiment.ImportAzimuthalIntegrationSettings(azint_settings);
logger.Info("Azimuthal integration: Q [{:.4f}, {:.4f}] 1/A, spacing {:.4f}, {} Q x {} azimuthal bins",
azint_settings.GetLowQ_recipA(), azint_settings.GetHighQ_recipA(),
azint_settings.GetQSpacing_recipA(), azint_settings.GetQBinCount(),
azint_settings.GetAzimuthalBinCount());
}
// --azint-only: azimuthal integration only (no spot finding / indexing / scaling). Rugnux reads
// the geometry and azimuthal-integration settings configured above off the experiment.
if (azint_only) {
ProcessConfig config;
config.mode = ProcessMode::AzimuthalIntegration;
config.start_image = start_image;
config.end_image = end_image;
config.stride = image_stride;
config.nthreads = nthreads;
config.output_prefix = output_prefix;
Rugnux process(reader, experiment, *dataset->pixel_mask, config);
g_active_process = &process;
std::signal(SIGINT, handle_sigint);
ProcessResult result;
try {
result = process.Run();
} catch (const std::exception &e) {
logger.Error("Processing failed: {}", e.what());
exit(EXIT_FAILURE);
}
g_active_process = nullptr;
std::cout << fmt::format("Processing time: {:.2f} s", result.processing_time_s) << std::endl;
std::cout << fmt::format("Frame rate: {:.2f} Hz", result.frame_rate_hz) << std::endl;
std::cout << fmt::format("Total throughput: {:.2f} MB/s", result.throughput_MBs) << std::endl;
if (result.cancelled)
logger.Warning("Processing was cancelled after {} images", result.images_processed);
return 0;
}
configure_offline_output(experiment, output_prefix);
// configure_offline_output applies the rugnux analysis defaults, one of which is the polarization
// factor, so an explicit --polarization has to land after it or it is silently overwritten.
if (polarization_factor) experiment.PolarizationFactor(polarization_factor.value());
ClearStoredCrystal(experiment); // shared with the viewer; -S / -C below override it
experiment.SpaceGroupNumber(space_group_number);
experiment.ImagesPerTrigger(images_to_process);
// Re-determine the unit cell from scratch: discard any cell stored in the input file so
// indexing is not biased by it. A stale or wrong stored cell otherwise resolves the indexing
// algorithm to FFBIDX and drives it to the wrong lattice (e.g. a non-cubic cell for a cubic
// crystal). A user-supplied -C cell still takes effect (clears to nullopt when absent).
experiment.SetUnitCell(fixed_reference_unit_cell);
// --refine-geometry defaults ON for stills whenever a reference cell is available (-C or a
// reference MTZ): that is exactly when the geometry bundle-adjust can act (it anchors on a known
// cell) and where it helps weak/sparse stills. It is a no-op for rotation (which has its own
// two-pass) and for de-novo stills (no cell yet), so auto-enabling it only where it does something
// avoids spurious "skipping" warnings. Explicit --refine-geometry[=N] still forces it on;
// --refine-geometry=off opts out.
if (!refine_geometry.has_value() && !refine_geometry_disabled) {
const bool is_stills = !(experiment.GetGoniometer().has_value() && !force_still);
if (is_stills && experiment.GetUnitCell().has_value())
refine_geometry = 200;
}
experiment.MaxSpotCount(max_spot_count_override.value_or(RUGNUX_MAX_SPOT_COUNT));
if (max_spot_count_override.has_value())
logger.Info("Max spot count overridden to {}", max_spot_count_override.value());
// X-ray bandwidth: CLI overrides the value carried in the dataset; otherwise
// keep whatever the dataset provided (0 / none -> monochromatic).
if (bandwidth_fwhm)
experiment.BandwidthFWHM(bandwidth_fwhm);
if (experiment.GetBandwidthFWHM())
logger.Info("X-ray bandwidth FWHM set to {:.4f}", experiment.GetBandwidthFWHM().value());
// Rotation vs stills. A dataset collected on a rotation goniometer is processed as rotation data
// (two-pass indexing) by default; --force-still forces per-frame stills. The rotation flags
// (-R / --single-pass-rotation / --force-rotation-lattice) still request rotation explicitly and
// choose the pass/lattice; at this point they show up as rotation_indexing already being set.
const bool has_goniometer = experiment.GetGoniometer().has_value();
if (force_still) {
if (rotation_indexing) {
logger.Error("--force-still conflicts with -R / --single-pass-rotation / --force-rotation-lattice");
exit(EXIT_FAILURE);
}
if (has_goniometer)
logger.Info("--force-still: treating the rotation dataset as independent stills");
} else if (!rotation_indexing && has_goniometer) {
rotation_indexing = true;
two_pass_rotation = true;
logger.Info("Dataset has a rotation goniometer axis: processing as rotation data (two-pass "
"indexing). Use --force-still to treat it as stills.");
}
// Scaling and merging are on by default (run_scaling initialised true); --no-merge turns them off
// for both rotation and stills, in which case only the per-image _process.h5 is written.
// Configure Indexing
IndexingSettings indexing_settings;
indexing_settings.Algorithm(indexing_algorithm);
indexing_settings.RotationIndexing(rotation_indexing);
if (rotation_indexing_range.has_value())
indexing_settings.RotationIndexingMinAngularRange_deg(rotation_indexing_range.value());
indexing_settings.GeomRefinementAlgorithm(refinement_algorithm);
indexing_settings.IndexIceRings(index_ice_rings);
experiment.ImportIndexingSettings(indexing_settings);
// --detect-ice-rings[=on|off] overrides the value carried in from the dataset (HDF5MetadataSource
// sets DetectIceRings from the master file's detect_ice_rings key); with no flag the dataset stands.
// Where the file says nothing at all, the default is the geometry's: on for rotation, off for
// stills. A rotation sweep sits on the same rings for the whole run, so ice there is a coherent
// systematic worth handling, and the ice-presence gate keeps it inert on a clean crystal; a serial
// stills run has too few spots per image to spend any of them on flagging.
if (detect_ice_rings.has_value())
experiment.DetectIceRings(detect_ice_rings.value());
else if (!dataset->file_detect_ice_rings.has_value())
experiment.DetectIceRings(rotation_indexing);
// Scale-fulls refits the per-frame scale on the rotation combined fulls; on by default for rotation
// data (where it lifts ISa substantially) and off for stills. --no-scale-fulls overrides.
const bool scale_fulls = scale_fulls_arg.value_or(rotation_indexing);
ScalingSettings scaling_settings = RugnuxDefaultScalingSettings(rotation_indexing);
scaling_settings.ScaleFulls(scale_fulls);
scaling_settings.SmoothGDegrees(smooth_g_deg_arg.value_or(scaling_settings.GetSmoothGDegrees()));
scaling_settings.RelativeBDegrees(relative_b_deg_arg.value_or(0.0)); // opt-in only; default off
if (no_scaling_corrections)
scaling_settings.CorrectionSurfaces(false);
scaling_settings.StillsPartialityRefine(!simple_stills_flag);
scaling_settings.ExpectedVarianceMerge(!no_expected_variance_merge);
if (ice_min_score_arg)
scaling_settings.IceMinScore(static_cast<float>(*ice_min_score_arg));
if (ice_min_spot_ratio_arg)
scaling_settings.IceMinSpotRatio(static_cast<float>(*ice_min_spot_ratio_arg));
if (d_min_scale_merge)
scaling_settings.HighResolutionLimit_A(d_min_scale_merge.value());
if (resolution_cutoff_method) scaling_settings.ResolutionCutoff(*resolution_cutoff_method);
if (resolution_cc_target) scaling_settings.ResolutionCCTarget(*resolution_cc_target);
if (report_shell_count) scaling_settings.ReportShellCount(*report_shell_count);
scaling_settings.MergeFriedel(!anomalous_mode);
scaling_settings.MinPartiality(min_partiality);
// Drop edge-of-sweep truncated fulls (rocking curve captured < this fraction) from the rot3d combine.
// Defaults ON (0.7) for rotation - removes the low-capture fulls that inflate low-res R-meas and
// slightly bias accuracy; off for non-rot3d (no combine). 0.7 (rather than 0.5) also strips the
// partiality-extrapolated fulls that dominate the intensity second moment on weakly-diffracting
// crystals, so the de-novo space-group search is no longer starved by the error-model I/sigma floor
// (e.g. a weakly-diffracting F-cubic or hexagonal crystal recovers its true space group instead of
// P1). An explicit --min-captured-fraction wins.
scaling_settings.MinCapturedFraction(min_captured_fraction_arg.value_or(scaling_settings.GetMinCapturedFraction()));
// Capture-aware systematic sigma defaults ON (1.0) for the rot3d combine - it down-weights the
// over-extrapolated under-captured fulls and, with the mosaicity fix, lifts rotation ISa/anomalous
// substantially. Off for non-rot3d (no combine). An explicit --capture-uncertainty always wins.
scaling_settings.CaptureUncertaintyCoeff(capture_uncertainty_arg.value_or(scaling_settings.GetCaptureUncertaintyCoeff()));
scaling_settings.ForcedMosaicity(forced_mosaicity_arg);
scaling_settings.MinCCForImage(min_image_cc / 100.0); // --min-image-cc is in percent; the setting is a fraction
// Rotation default: 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 - see
// Rugnux.cpp - so the cut being imperfect only means the second opinion contributes nothing.
scaling_settings.SearchMinZeta(search_min_zeta_arg.value_or(scaling_settings.GetSearchMinZeta()));
scaling_settings.OutlierRejectNsigma(
outlier_reject_nsigma.value_or(scaling_settings.GetOutlierRejectNsigma()));
experiment.ImportScalingSettings(scaling_settings);
// Integration radii: r1 (signal box), r2/r3 (background annulus).
if (integration_radius_arg) {
std::vector<float> rr;
std::stringstream ss(*integration_radius_arg);
std::string tok;
while (std::getline(ss, tok, ',')) {
trim_in_place(tok);
if (!tok.empty())
rr.push_back(parse_number_arg<float>(tok.c_str(), "--integration-radius", logger,
0.1f, 1000.0f));
}
float r1, r2, r3;
if (rr.size() == 1) { r1 = rr[0]; r2 = r1 + 2.0f; r3 = r1 + 4.0f; }
else if (rr.size() == 3) { r1 = rr[0]; r2 = rr[1]; r3 = rr[2]; }
else { logger.Error("--integration-radius expects r1 or r1,r2,r3"); return 1; }
BraggIntegrationSettings bis = experiment.GetBraggIntegrationSettings();
bis.R1(r1).R2(r2).R3(r3);
experiment.ImportBraggIntegrationSettings(bis);
logger.Info("Integration radii set to r1={:.1f} r2={:.1f} r3={:.1f}", r1, r2, r3);
} else if (!rotation_indexing) {
// 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. An explicit --integration-radius always wins.
BraggIntegrationSettings bis = experiment.GetBraggIntegrationSettings();
bis.R1(6.0f).R2(8.0f).R3(12.0f);
experiment.ImportBraggIntegrationSettings(bis);
logger.Info("Stills integration radii default to r1=6.0 r2=8.0 r3=12.0 (override with --integration-radius)");
}
if (integrator_mode) {
BraggIntegrationSettings bis = experiment.GetBraggIntegrationSettings();
bis.Integrator(*integrator_mode);
experiment.ImportBraggIntegrationSettings(bis);
logger.Info("Integrator set to {}", *integrator_mode == IntegratorMode::BoxSum ? "box-sum"
: *integrator_mode == IntegratorMode::ProfileGaussian ? "profile (gaussian)"
: "profile (empirical)");
}
if (integration_d_min_arg) {
BraggIntegrationSettings bis = experiment.GetBraggIntegrationSettings();
// 0 spells "no limit" for the sibling resolution options, so it has to mean the same here.
bis.DMinLimit_A(*integration_d_min_arg > 0.0
? std::optional<float>(static_cast<float>(*integration_d_min_arg))
: std::nullopt);
experiment.ImportBraggIntegrationSettings(bis);
}
if (max_hkl_arg) {
BraggIntegrationSettings bis = experiment.GetBraggIntegrationSettings();
bis.MaxHKL(static_cast<int>(*max_hkl_arg));
experiment.ImportBraggIntegrationSettings(bis);
logger.Info("Predicting reflections with |h|,|k|,|l| <= {} (overriding the per-crystal bound)", *max_hkl_arg);
}
if (background_trim_arg) {
BraggIntegrationSettings bis = experiment.GetBraggIntegrationSettings();
bis.BackgroundTrimFraction(static_cast<float>(*background_trim_arg));
experiment.ImportBraggIntegrationSettings(bis);
logger.Info("Background ring: symmetric trimmed mean at {:.2f} instead of the default high-side clip "
"(monochromatic data; broadband always clips)", *background_trim_arg);
}
if (background_clip_arg) {
BraggIntegrationSettings bis = experiment.GetBraggIntegrationSettings();
bis.BackgroundClipNSigma(static_cast<float>(*background_clip_arg));
experiment.ImportBraggIntegrationSettings(bis);
logger.Info("Background ring: high-side clip at {:.1f} sigma", *background_clip_arg);
}
if (background_radial_given) {
BraggIntegrationSettings bis = experiment.GetBraggIntegrationSettings();
bis.BackgroundRadialCorrection(background_radial_arg);
experiment.ImportBraggIntegrationSettings(bis);
logger.Info("Background ring: radial curvature correction {}",
background_radial_arg.has_value() ? (*background_radial_arg ? "on" : "off") : "auto");
}
SpotFindingSettings spot_settings;
spot_settings.enable = true;
spot_settings.indexing = true;
spot_settings.signal_to_noise_threshold = sigma_spot_finding;
spot_settings.photon_count_threshold = photon_count_threshold_spot_finding;
// Detection defaults differ by workflow; each is overridden by its flag, which always wins.
// - min-pix: choosing it per image (unset) only means something where each frame is indexed on its
// own. Rotation indexing builds ONE lattice from all frames, so it keeps the fixed value.
// - adaptive detection: on by default for both workflows.
// The high-resolution limit is NOT one of them: unset means "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.
spot_settings.min_pix_per_spot = min_pix_per_spot;
if (rotation_indexing && !spot_settings.min_pix_per_spot.has_value())
spot_settings.min_pix_per_spot = 2;
spot_settings.adaptive_threshold = adaptive_spots.value_or(true);
spot_settings.high_resolution_limit = d_min_spot_finding;
spot_settings.false_pixels_per_frame = false_pixels_per_frame;
if (d_max_spot_finding > 0.0f)
spot_settings.low_resolution_limit = d_max_spot_finding;
// Validate the assembled spot-finding settings the same way the online receivers do (broker and
// receiver call this same function). It enforces the cross-field constraints that per-argument
// bounds cannot express - in particular that the low-resolution limit is coarser than the
// high-resolution limit, so --spot-low-resolution below the high-res cut no longer silently
// rejects every pixel.
try {
DiffractionExperiment::CheckDataProcessingSettings(spot_settings);
} catch (const std::exception &e) {
logger.Error("Invalid spot-finding settings: {}", e.what());
return 1;
}
// Run the shared full-analysis workflow (rotation indexing + scaling/merging live in
// Rugnux; the experiment above carries all algorithm settings).
ProcessConfig config;
config.mode = ProcessMode::FullAnalysis;
config.start_image = start_image;
config.end_image = end_image;
config.stride = image_stride;
config.nthreads = nthreads;
config.output_prefix = output_prefix;
config.spot_finding = spot_settings;
config.rotation_indexing = rotation_indexing;
config.two_pass_rotation = two_pass_rotation;
config.rotation_postrefine_geometry = rotation_postrefine_geometry;
config.rotation_indexing_image_count = rotation_indexing_image_count;
config.forced_rotation_lattice = forced_rotation_lattice;
config.refine_geometry = refine_geometry;
config.run_scaling = run_scaling;
config.scaling_iter = scaling_iter;
config.reference_data = reference_data;
config.reference_has_free_flags = reference_has_free_flags;
config.observation_dump_path = dump_observations;
config.model_path = model_pdb;
// When merging, the merged reflections (.mtz/.cif) are the wanted output; skip the large
// _process.h5 unless explicitly requested. Without merging, the _process.h5 is the only output.
config.write_process_h5 = run_scaling ? write_process_h5_flag : true;
Rugnux process(reader, experiment, *dataset->pixel_mask, config);
g_active_process = &process;
std::signal(SIGINT, handle_sigint);
ProcessResult result;
try {
result = process.Run();
} catch (const std::exception &e) {
logger.Error("Processing failed: {}", e.what());
exit(EXIT_FAILURE);
}
g_active_process = nullptr;
// The space-group search is rendered here (not in the library) so the viewer does not emit it on
// stdout and the CLI owns the format.
if (result.space_group_search.has_value())
std::cout << std::endl << SearchSpaceGroupResultToText(*result.space_group_search) << std::endl;
if (!result.merge_statistics_text.empty())
std::cout << std::endl << result.merge_statistics_text << std::endl;
// Report statistics
std::cout << fmt::format("Processing time: {:.2f} s", result.processing_time_s) << std::endl;
std::cout << fmt::format("Frame rate: {:.2f} Hz", result.frame_rate_hz) << std::endl;
std::cout << fmt::format("Total throughput:{:.2f} MB/s", result.throughput_MBs) << std::endl;
if (result.indexing_rate.has_value())
std::cout << fmt::format("Indexing rate: {:.2f}%", result.indexing_rate.value() * 100.0) << std::endl;
// Final one-line summary of the adopted crystal (whether de-novo determined or fixed with -S),
// so it is not buried in the space-group-search block (which is de-novo only) or only in the mmCIF.
// Only when something actually indexed: with a zero indexing rate the cell is whatever the lattice
// search happened to return and no reflection was measured on it, so printing it as the run's answer
// states a result the data do not support.
const bool anything_indexed = result.indexing_rate.value_or(0.0f) > 0.0f;
if (result.space_group_number.has_value() && anything_indexed) {
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_number(
static_cast<int>(result.space_group_number.value()));
std::string line = fmt::format("Space group: {} (No. {})", sg ? sg->short_name() : "?",
result.space_group_number.value());
// Name every group the data cannot separate, not just the representative. Some pairs share
// their whole absence pattern - an enantiomorphic pair (P4_1 vs P4_3), or I23 vs I2_13 and
// I222 vs I2_12_12_1, where the screw condition h00: h=2n is already implied by the
// I-centering - so the choice among them is a convention, not a measurement. The search
// reports the representative as the lowest space-group number; saying so here keeps the
// summary from claiming a decision the diffraction did not make.
if (result.space_group_search.has_value())
for (const auto &alt : result.space_group_search->alternatives)
line += fmt::format(" or {} (No. {})", alt.short_name(), alt.number);
if (result.space_group_search.has_value() && !result.space_group_search->alternatives.empty())
line += " - indistinguishable from these data";
std::cout << line << std::endl;
}
if (result.consensus_cell.has_value() && anything_indexed) {
const auto &c = result.consensus_cell.value();
std::cout << fmt::format("Unit cell: a={:.2f} b={:.2f} c={:.2f} alpha={:.2f} beta={:.2f} gamma={:.2f}",
c.a, c.b, c.c, c.alpha, c.beta, c.gamma) << std::endl;
}
if (result.indexing_rate.has_value() && !anything_indexed)
std::cout << "No image indexed - no crystal lattice was determined from this dataset" << std::endl;
// Each stage timer measures wall time inside one worker, so it counts the time that worker spent
// BLOCKED on a contended resource - above all the single GPU - as well as its own work. With N
// workers those waits overlap, so the per-image cost is the worker mean divided by the worker
// count, not the mean itself: printed raw at 32 workers these numbers overstate the truth by more
// than an order of magnitude, which is exactly backwards for the one output people tune against.
// Dividing is a lower bound (a worker that is idle rather than blocked is not counted), so the
// remainder is shown against the loop's own wall time rather than hidden.
const auto &t = result.mean_processing_time;
const double per_worker = std::max(1, nthreads);
auto stage = [&](const char *name, float mean_s) {
// A stage that never ran has no mean at all - the per-image indexing and scaling timers are
// never fed on the two-pass rotation path, where the lattice is forced and the merge happens
// outside the image loop. Say nothing rather than printing nan.
return std::isfinite(mean_s)
? fmt::format(" {} {:.2f}", name, mean_s * 1e3 / per_worker) : std::string();
};
std::cout << fmt::format("Per-image cost (ms, {} workers):", nthreads)
<< stage("decompress", t.compression) << stage("preprocess", t.preprocessing)
<< stage("azint", t.azint) << stage("spot-finding", t.spot_finding)
<< stage("indexing", t.indexing) << stage("refinement", t.refinement)
<< stage("indexing-analysis", t.indexing_analysis) << stage("prediction", t.bragg_prediction)
<< stage("integration", t.integration) << stage("scaling", t.image_scale)
<< stage("total", t.processing) << std::endl;
// The stage timers only cover the per-image loop. On a rotation run the first-pass indexing and the
// scaling/merging sit outside it and can be a large share of the run, so report the loop against the
// whole run instead of leaving the difference unexplained. Both are the last pass only: a two-pass
// rotation run does all of this twice.
if (result.images_processed > 0 && result.image_loop_time_s > 0.0) {
const double loop_ms = result.image_loop_time_s * 1e3 / static_cast<double>(result.images_processed);
const double outside_s = result.processing_time_s - result.image_loop_time_s;
std::cout << fmt::format("Per-image wall: {:.2f} ms in the image loop ({:.2f} s); "
"{:.2f} s outside it (first-pass indexing, scaling/merging) [last pass]",
loop_ms, result.image_loop_time_s, std::max(0.0, outside_s)) << std::endl;
}
if (result.cancelled)
logger.Warning("Processing was cancelled after {} images", result.images_processed);
return EXIT_SUCCESS;
}
int main(int argc, char **argv) {
try {
return RunRugnux(argc, argv);
} catch (const std::exception &e) {
Logger("rugnux").Error("{}", e.what());
return EXIT_FAILURE;
}
}