The rugnux library wrote its results itself: _process.h5 through a FileWriter, the merged/twin/_mr reflection files, the unmerged MTZs, the maps and their MTZ, the placed models, _detector.jpg and _plot.txt, and on a doubled cell it renamed files on disk. It now writes none of them. - MtzTable (common/MtzTable.h): an MTZ file's header, columns and batch headers with no file behind it. It replaces MergedReflectionsMessage and MapCoefficientsMessage in the END message, with one CBOR encoding. WriteMtz (writer/WriteRugnuxFiles.h) is the one serialiser, used by rugnux and jfjoch_writer alike; it refuses a table whose columns differ in length, name a missing dataset, carry a malformed batch header or name a space group gemmi does not know. MergedMtzTable and UnmergedMtzTable build the tables the old writers built. - ProcessResult::outputs (RugnuxOutputs) holds, for the output pass only: the answer's merge (MTZ table plus what the .cif/.hkl are written from), the twin hypothesis' and the model's choice's merges, the unmerged and unmerged-partials tables, the maps (coefficients always; the grids only with ProcessConfig::keep_map_grids), the placed models, the detector JPEG and the per-image plot table, and the start message of the no-lattice _process.h5. Map RMS is taken from the grid in memory, as the CCP4 header stated it, whether or not the map is kept. - The per-image analysis goes to a caller-supplied RugnuxImageSink; rugnux and the viewer give a FileWriter-based ProcessFileSink. The geometry pre-pass no longer makes a _01_process.h5. - The doubled cell keeps both output sets: the answer's in outputs, the other's in DoubledCell::other_outputs with its suffix; the report names files by suffix after the caller's prefix. - The output prefix, write_process_h5 and the observation dump path are gone from ProcessConfig; --dump-observations is a callback. - What used to be gated on having files to write (model validation and MR, the twin-hypothesis merges, the finalist ledger, the scaled fulls, the unmerged tables) is gated on the output pass instead, so a caller with no prefix gets the same results. - RugnuxOutputFiles (new library JFJochRugnuxOutput, linked by rugnux, the viewer and the tests) writes every file from the result with the names and formats rugnux has always used. The viewer writes its job's files the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
13 KiB
C++
256 lines
13 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 <cmath>
|
|
#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 "cufft"; // the GPU implementation; rugnux's -X fft is the algorithm
|
|
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 &output_prefix,
|
|
bool write_process_h5,
|
|
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 (!output_prefix.empty())
|
|
add("-o", output_prefix);
|
|
// Every CPU the run may use is what rugnux takes without -N, on whatever machine runs the command.
|
|
if (!config.nthreads_auto)
|
|
add("-N", std::to_string(config.nthreads));
|
|
if (!azint && !calibration) {
|
|
if (!config.reference_path.empty()) {
|
|
add("-z", config.reference_path);
|
|
if (!config.reference_column.empty())
|
|
add("--reference-column", config.reference_column);
|
|
}
|
|
if (config.model)
|
|
add(config.force_mr ? "--mr" : "--model", config.model->name);
|
|
}
|
|
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");
|
|
if (!config.calibration_refine_tilt)
|
|
args.emplace_back("--no-refine-tilt");
|
|
// 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));
|
|
// --max-spots pins the budget, which is otherwise measured from the data.
|
|
if (!config.measure_spot_budget)
|
|
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();
|
|
// An --integration-radius would switch off the radius the pre-scan measures.
|
|
if (!config.adaptive_integration_radius) {
|
|
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()));
|
|
// Air by default, so emit the flag only for the other media - which nothing in the file can
|
|
// state, and which therefore has to travel with the command line to reproduce the run.
|
|
if (bragg.GetFlightPath() == FlightPathMedium::Helium)
|
|
add("--flight-path", "helium");
|
|
else if (bragg.GetFlightPath() == FlightPathMedium::Vacuum)
|
|
add("--flight-path", "vacuum");
|
|
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 (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)));
|
|
// An infinite d_max is the default of a run with no low limit, so d_min alone restates it.
|
|
if (const auto r = sc.GetReportResolutionRange_A())
|
|
args.push_back("--report-resolution=" + num(r->d_min)
|
|
+ (std::isfinite(r->d_max) ? "," + num(r->d_max) : ""));
|
|
} 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();
|
|
}
|