Ice handling was gated on a measurement the run only made AFTER the images had been processed, so the per-image pass could not use it. The flagging therefore ran unconditionally: ice-band spots were ordered last in the --max-spots budget and held out of the indexer seed and the geometry refinement on every crystal, iced or not. The eleven bands are fixed geometry holding 16-26 % of the unique reflections whether or not there is ice, so on a clean crystal that discards a fifth of the spots - the strongest first - for nothing. Measured on a crystal whose gate never fires, that moved the merged data by a mean of 0.85 sigma against a run-to-run floor of 9.3e-5. Measure it in the first pass instead. That pass already looks at ~100 images spread over the sweep, and it already stops at the spot finder, so it sees the azimuthal profile for the smooth channel and the unfiltered connected components for the spot channel. Both counts SpotAnalyze takes are pre-filter, so pooling them there is the run's own verdict, reached before anything has been discarded and in time for the pass that acts on it. Where the sample sees no ice, the run indexes on the ice-band spots too. It has to be the whole sample: the spot channel is a ratio pooled over images, because one frame carries a handful of control spots. A per-image gate is not an alternative - two of the crystals whose indexing this rescues fire on that channel alone, at profile scores of 1.12 and 1.22, so gating per image on the profile score would drop exactly the cases that matter. This also removes the first-pass spot reuse, and with it --redo-rotation-spots and the reuse path. Finding the ~100 first-pass spots costs little, and reusing was actively wrong here: the stored spots were found online at the acquisition's threshold and have already had their ice-band entries ordered last and dropped by its spot budget, so counting ice from them under-reads it by construction, and the lattice search never saw the spot-finding settings at all. It also removes the need for the machinery that re-found spots whenever a spot-finding option was named, which made those options impossible to A/B. IndexAndRefine cached index_ice_rings at construction, which happens before the first pass; it holds a reference to the experiment, so it now reads the setting where it uses it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
193 lines
9.1 KiB
C++
193 lines
9.1 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "RugnuxCommandLine.h"
|
|
#include "../common/DiffractionExperiment.h"
|
|
|
|
#include <sstream>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
std::string quote_if_needed(const std::string &s) {
|
|
if (s.find_first_of(" \t\"'") == std::string::npos)
|
|
return s;
|
|
std::string out = "\"";
|
|
for (char c: s) {
|
|
if (c == '"' || c == '\\')
|
|
out += '\\';
|
|
out += c;
|
|
}
|
|
out += '"';
|
|
return out;
|
|
}
|
|
|
|
const char *indexing_alg_flag(IndexingAlgorithmEnum a) {
|
|
switch (a) {
|
|
case IndexingAlgorithmEnum::FFBIDX: return "ffbidx";
|
|
case IndexingAlgorithmEnum::FFT: return "fft";
|
|
case IndexingAlgorithmEnum::FFTW: return "fftw";
|
|
case IndexingAlgorithmEnum::None: return "none";
|
|
case IndexingAlgorithmEnum::Auto:
|
|
default: return "auto";
|
|
}
|
|
}
|
|
|
|
const char *refine_flag(GeomRefinementAlgorithmEnum r) {
|
|
switch (r) {
|
|
case GeomRefinementAlgorithmEnum::None: return "none";
|
|
case GeomRefinementAlgorithmEnum::OrientationOnly: return "orientation";
|
|
case GeomRefinementAlgorithmEnum::Flex: return "flex";
|
|
case GeomRefinementAlgorithmEnum::BeamCenter:
|
|
default: return "beam_and_lattice";
|
|
}
|
|
}
|
|
|
|
std::string num(double v) {
|
|
std::ostringstream o;
|
|
o << v;
|
|
return o.str();
|
|
}
|
|
}
|
|
|
|
std::string RugnuxCommandLine(const ProcessConfig &config,
|
|
const DiffractionExperiment &experiment,
|
|
const std::string &input_file) {
|
|
std::vector<std::string> args;
|
|
const bool azint = (config.mode == ProcessMode::AzimuthalIntegration);
|
|
args.emplace_back("rugnux");
|
|
if (azint)
|
|
args.emplace_back("--azint-only");
|
|
|
|
auto add = [&](const std::string &flag, const std::string &val) {
|
|
args.push_back(flag);
|
|
args.push_back(val);
|
|
};
|
|
|
|
if (!config.output_prefix.empty())
|
|
add("-o", config.output_prefix);
|
|
add("-N", std::to_string(config.nthreads));
|
|
if (config.start_image != 0)
|
|
add("-s", std::to_string(config.start_image));
|
|
if (config.end_image >= 0)
|
|
add("-e", std::to_string(config.end_image));
|
|
if (config.stride != 1)
|
|
add("-t", std::to_string(config.stride));
|
|
|
|
if (azint) {
|
|
const auto a = experiment.GetAzimuthalIntegrationSettings();
|
|
add("--azim-min-q", num(a.GetLowQ_recipA()));
|
|
// An unset maximum Q means "to the detector edge"; emitting the resolved number would pin it
|
|
// to this run's geometry, so leave the flag out and let it resolve again.
|
|
if (const auto high_q = a.GetRequestedHighQ_recipA())
|
|
add("--azim-max-q", num(*high_q));
|
|
add("--azim-q-spacing", num(a.GetQSpacing_recipA()));
|
|
add("--azim-phi-bins", std::to_string(a.GetAzimuthalBinCount()));
|
|
add("--polarization-correction", a.IsPolarizationCorrection() ? "on" : "off");
|
|
add("--solid-angle-correction", a.IsSolidAngleCorrection() ? "on" : "off");
|
|
} else {
|
|
const auto &sf = config.spot_finding;
|
|
add("--spot-sigma", num(sf.signal_to_noise_threshold));
|
|
add("--spot-threshold", std::to_string(sf.photon_count_threshold));
|
|
// Emit the explicit flag rather than relying on the default, so the command line reproduces
|
|
// this run even if the default changes.
|
|
args.emplace_back(sf.adaptive_threshold ? "--adaptive-spots" : "--no-adaptive-spots");
|
|
if (sf.adaptive_threshold)
|
|
add("--spot-false-pixels", num(sf.false_pixels_per_frame));
|
|
// min-pix is chosen per image unless an explicit value is given, so emit --min-pix-per-spot only
|
|
// when a fixed min-pix was selected; its absence selects the adaptive per-image path.
|
|
if (sf.min_pix_per_spot.has_value())
|
|
add("--min-pix-per-spot", std::to_string(*sf.min_pix_per_spot));
|
|
// Same for the spot-finding limit: absent means "as far as the detector reaches".
|
|
if (sf.high_resolution_limit.has_value())
|
|
add("--spot-high-resolution", num(*sf.high_resolution_limit));
|
|
add("--max-spots", std::to_string(experiment.GetMaxSpotCount()));
|
|
|
|
const auto idx = experiment.GetIndexingSettings();
|
|
add("-X", indexing_alg_flag(idx.GetAlgorithm()));
|
|
add("-r", refine_flag(idx.GetGeomRefinementAlgorithm()));
|
|
|
|
if (const auto sg = experiment.GetSpaceGroupNumber())
|
|
add("-S", std::to_string(*sg));
|
|
if (const auto uc = experiment.GetUnitCell()) {
|
|
std::ostringstream o;
|
|
o << uc->a << "," << uc->b << "," << uc->c << "," << uc->alpha << "," << uc->beta << "," << uc->gamma;
|
|
add("-C", o.str());
|
|
}
|
|
if (const auto bw = experiment.GetBandwidthFWHM())
|
|
add("--bandwidth", num(*bw));
|
|
|
|
const auto bragg = experiment.GetBraggIntegrationSettings();
|
|
std::ostringstream radii;
|
|
radii << bragg.GetR1() << "," << bragg.GetR2() << "," << bragg.GetR3();
|
|
add("--integration-radius", radii.str());
|
|
// Background ring: the CLI defaults to the 4 sigma high-side clip, so emit a flag only when the
|
|
// GUI chose otherwise. The trim is the alternative estimator (it clears the clip), and a clip of
|
|
// 0 with no trim is the plain ring mean, which needs the flag to be reproduced.
|
|
if (bragg.GetBackgroundTrimFraction() > 0.0f)
|
|
add("--background-trim", num(bragg.GetBackgroundTrimFraction()));
|
|
else if (bragg.GetBackgroundClipNSigma() != BraggIntegrationSettings().GetBackgroundClipNSigma())
|
|
add("--background-clip", num(bragg.GetBackgroundClipNSigma()));
|
|
if (const auto max_hkl = bragg.GetMaxHKL())
|
|
add("--max-hkl", std::to_string(*max_hkl));
|
|
// Unset means "to the detector edge"; emitting the resolved number would pin it to this run's
|
|
// geometry, so leave the flag out and let it resolve again.
|
|
if (const auto d_min = bragg.GetDMinLimit_A())
|
|
add("--integration-high-resolution", num(*d_min));
|
|
|
|
if (config.rotation_indexing) {
|
|
if (config.two_pass_rotation)
|
|
// -R takes an optional argument, which getopt only accepts attached (-R100), never as a
|
|
// separate token - so emit it joined or the copied command line will not re-parse.
|
|
args.push_back("-R" + std::to_string(config.rotation_indexing_image_count));
|
|
else
|
|
args.emplace_back("--single-pass-rotation");
|
|
} else if (experiment.GetGoniometer().has_value()) {
|
|
// rotation dataset processed as stills -> the user overrode the default with --force-still
|
|
args.emplace_back("--force-still");
|
|
}
|
|
|
|
// Stills geometry-refinement two-pass (--refine-geometry). getopt takes its optional argument
|
|
// only when attached (=N), never as a separate token, so emit it joined. The CLI defaults it ON
|
|
// for a stills-with-cell run, so emit =off when the GUI turned it off in that same case, to
|
|
// reproduce the GUI's choice in the copied command line.
|
|
const bool stills_with_cell = !config.rotation_indexing && experiment.GetUnitCell().has_value();
|
|
if (config.refine_geometry.has_value())
|
|
args.push_back("--refine-geometry=" + std::to_string(*config.refine_geometry));
|
|
else if (stills_with_cell)
|
|
args.emplace_back("--refine-geometry=off");
|
|
|
|
// Rotation two-pass geometry post-refine. The CLI defaults it ON for a rotation run, so emit the
|
|
// disable flag only when the GUI turned it off on a rotation dataset (to reproduce that choice).
|
|
if (config.rotation_indexing && !config.rotation_postrefine_geometry)
|
|
args.emplace_back("--rotation-no-postrefine");
|
|
|
|
// Merging is on by default; emit --no-merge only when it was turned off.
|
|
if (config.run_scaling) {
|
|
const auto sc = experiment.GetScalingSettings();
|
|
if (!sc.GetMergeFriedel())
|
|
args.emplace_back("-A");
|
|
if (!sc.GetStillsPartialityRefine())
|
|
args.emplace_back("--simple-stills");
|
|
if (!sc.GetExpectedVarianceMerge())
|
|
args.emplace_back("--no-expected-variance-merge");
|
|
// When merging, the CLI skips the large _process.h5 unless asked; emit the flag when it is
|
|
// wanted so a copied command matches the GUI's "Save _process.h5" choice. (write_merged has
|
|
// no CLI equivalent - the CLI always writes the .mtz/.cif when merging.)
|
|
if (config.write_process_h5)
|
|
args.emplace_back("--write-process-h5");
|
|
} else {
|
|
args.emplace_back("--no-merge");
|
|
}
|
|
}
|
|
|
|
args.push_back(input_file);
|
|
|
|
std::ostringstream cmd;
|
|
for (size_t i = 0; i < args.size(); i++) {
|
|
if (i)
|
|
cmd << ' ';
|
|
cmd << quote_if_needed(args[i]);
|
|
}
|
|
return cmd.str();
|
|
}
|