The angles a rotation dataset stores are the COMMANDED ones, so a stage whose travel is miscalibrated leaves no trace in the header - every angle is self-consistently wrong. No existing parameter can absorb it either: the cell scale, the axis direction, the detector distance and the beam centre are all orthogonal to an error in rotation MAGNITUDE. So fit it as what it is - one scalar k, the ratio of the travel to the commanded angle - on the rocking events the geometry post-refinement already builds, after step A so the cell scale and the axis direction are fixed and k is the only free quantity. Two details decide whether the number means anything. The angle enters measured from the CENTRE of the sweep: the reference orientation was fitted against the commanded angles and has already absorbed their mean error, so measured from the goniometer's zero instead a constant missetting about the spindle leaks into k with a gain of <phi>/<phi^2>, which depends only on where the sweep happens to sit - on a short sweep starting near zero a 0.14 deg missetting fakes 1.4 % of k. Referred to the sweep centre that leak is identically zero at any width. And the robust loss is scaled to the scatter the events actually have, which varies by more than a decade between datasets, so any fixed constant is either inert or throws away real data. A stage fault is rare and a 1 % angle correction applied to a healthy dataset would damage it silently, so the correction is committed only when every test passes: at least 30 deg of sweep and 5000 events, |k-1| over 0.5 %, a misorientation of at least 0.5 deg at each end of the sweep, and the same k from every fifth of the sweep left out. The last test is not optional. A second lattice that dominates ONE END of a sweep - exactly what happens where the primary stops indexing - fakes a k that passes the other two, and the hkl-hash split used elsewhere in this file cannot see it, because both of its folds sit at the same angles and anything structured in phi survives in both. When it commits, the second pass re-integrates against the corrected angles. The pre-pass mosaicity is dropped with it: that is a width in degrees fitted against angles the second pass has just stopped using, and since the override can only ever raise the second pass's own estimate, carrying it over would hold the second pass at the rocking width the uncorrected angles produced - the correction half-applied. --rotation-scale asserts a known stage calibration by hand and overrides the fit. On the 38-crystal rotation battery the gate fires on exactly one dataset, at k = 1.01318 with 0.74 of that k surviving every fifth left out. The largest of the other 37 is 1.00211, which fails the end-error test; 34 of them sit below 1.0006. On the one that fires: R_meas 39.2 -> 23.9 % (XDS 37.1) CC1/2 86.5 -> 96.0 % (XDS 94.3) CC1/2 outer 1.4 -> 53.4 % (XDS 42.5) unique refl 40990 -> 41540 (XDS 41322) observations 74975 -> 103858 (XDS 129322) mosaicity 0.181 -> 0.159 deg which takes it from losing to XDS on R_meas, CC1/2 and outer-shell CC1/2 to beating it on all three, and the mosaicity drop is the inflation the uncorrected angles were producing. Its low-resolution R_meas is the one number that moves the wrong way, 12.0 -> 13.9 %, still well inside XDS's 18.3. No space group moves anywhere, and every other crystal's merge is unchanged beyond the two-pass loop's own jitter - measured here as the spread of the post-refined distance across arms that do not touch post-refinement at all, which is larger than anything this commit produces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
225 lines
11 KiB
C++
225 lines
11 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,
|
|
const std::string &calibrant_name) {
|
|
std::vector<std::string> args;
|
|
const bool azint = (config.mode == ProcessMode::AzimuthalIntegration);
|
|
const bool calibration = (config.mode == ProcessMode::Calibration);
|
|
args.emplace_back("rugnux");
|
|
if (azint) {
|
|
args.emplace_back("--mode");
|
|
args.emplace_back("azint");
|
|
} else if (calibration) {
|
|
args.emplace_back("--mode");
|
|
args.emplace_back("calibration");
|
|
}
|
|
|
|
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));
|
|
// Beam-stop detection is ON by default in the CLI, so emit only a deviation from that. getopt takes
|
|
// an optional argument only when attached (=N), never as a separate token.
|
|
if (!config.detect_beam_stop.has_value())
|
|
args.emplace_back("--detect-beam-stop=off");
|
|
else if (*config.detect_beam_stop != 60)
|
|
args.push_back("--detect-beam-stop=" + std::to_string(*config.detect_beam_stop));
|
|
|
|
if (calibration) {
|
|
if (!calibrant_name.empty())
|
|
add("--calibrant", calibrant_name);
|
|
add("--calibration", config.calibration_method == CalibrationMethod::Rings ? "rings" : "spots");
|
|
// The rings method reads the ring at every azimuth, and the GUI may have raised the sector
|
|
// count for this run, so emit the value actually used rather than relying on the CLI default.
|
|
if (config.calibration_method == CalibrationMethod::Rings)
|
|
add("--azim-phi-bins",
|
|
std::to_string(experiment.GetAzimuthalIntegrationSettings().GetAzimuthalBinCount()));
|
|
} else 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());
|
|
if (bragg.GetStencilKSigma() > 0.0f)
|
|
add("--integration-stencil", num(bragg.GetStencilKSigma()));
|
|
// 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");
|
|
if (config.rotation_scale.has_value())
|
|
add("--rotation-scale", num(*config.rotation_scale));
|
|
|
|
// 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");
|
|
// The low-resolution limit is on by default at the same value in both, so emit it only when
|
|
// it differs - including 0, which is how the CLI spells "no limit".
|
|
const auto d_max = sc.GetLowResolutionLimit_A();
|
|
const auto d_max_default = ScalingSettings().GetLowResolutionLimit_A();
|
|
if (d_max != d_max_default)
|
|
args.push_back("--scaling-low-resolution=" + num(d_max.value_or(0.0)));
|
|
} 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();
|
|
}
|