From fa912c7ff5be8e0f74b6b5def38d03beaa820564 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Wed, 28 Oct 2026 17:14:16 +0100 Subject: [PATCH 1/4] rugnux: several rotation sweeps are told apart from the inputs; --sweeps separate processes each alone The inputs are planned as sweeps (RugnuxSweeps): one per CBF/marCCD/SMV file-name template - the name with its frame number taken out, so any frame of a sweep names it and `rugnux a_*.cbf` is the one sweep a, where several frames of one sweep used to be refused - and one per rotation master. Several stills masters stay one stills run as before (the first master decides, and the stills reader refuses a rotation one among them); a rotation master among stills is refused, and so are sweeps at different wavelengths. One input is not planned at all. Other sweeps beside the given ones are named in the log and not processed. --sweeps separate|joint. separate processes each sweep alone, as if it were the only input, into _s_* with its own log and report; a sweep that fails is reported and the others go on. joint, the default, refuses with several sweeps until the joint pass exists. -s/-e and --force-still are refused with several sweeps. rugnux_cli.cpp: the one-input code is taken out of RunRugnux. The options and what RunRugnux set up before parsing are members of RugnuxCli (Parse), and one input is a RugnuxInputRun copied from it, in two halves: PrepareRugnuxInput (reading the input, the experiment and the run's configuration) and RunPreparedInput (running it and writing its files); the locals the second half reads are members. The code itself is as it was but for exit(EXIT_FAILURE) in the per-input code, which is now an exception the caller turns into the exit code, so that one sweep's failure does not end the others. With one input the outputs are identical: report (but for the times and command line) and .hkl byte for byte against the rc181 binary on a CBF small-molecule sweep and an NXmx rotation set, and on the first sweep of a --sweeps separate run. Tests: RugnuxSweepsTest (templates, single input untouched, frames by template, masters rotation / stills / mixed / wavelength, the stills readers and mixtures left alone, sibling sweeps). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37 --- docs/RUGNUX_CLI.md | 25 +- rugnux/CMakeLists.txt | 3 +- rugnux/RugnuxSweeps.cpp | 140 ++++++++++++ rugnux/RugnuxSweeps.h | 56 +++++ rugnux/rugnux_cli.cpp | 454 +++++++++++++++++++++++++++---------- tests/CMakeLists.txt | 1 + tests/RugnuxSweepsTest.cpp | 132 +++++++++++ 7 files changed, 686 insertions(+), 125 deletions(-) create mode 100644 rugnux/RugnuxSweeps.cpp create mode 100644 rugnux/RugnuxSweeps.h create mode 100644 tests/RugnuxSweepsTest.cpp diff --git a/docs/RUGNUX_CLI.md b/docs/RUGNUX_CLI.md index 9a73a5532..6081bc20d 100644 --- a/docs/RUGNUX_CLI.md +++ b/docs/RUGNUX_CLI.md @@ -30,9 +30,11 @@ rugnux -o myrun dataset_master.h5 # NXmx / EIGER / Jungfraujoch mas rugnux -o myrun sweep_00001.cbf # CBF, marCCD or SMV sweep: name one frame rugnux -o myrun dataset_master.h5 model.cif # with a model (or --model model.cif) rugnux -o myrun x_master.h5 http://host:5232/ # follow a broker's collection while it is written +rugnux -o myrun --sweeps separate a_master.h5 b_master.h5 # several sweeps, each alone ``` -Chosen when the file's goniometer axis turns. One sweep per run. A model is placed (rigid body, or +Chosen when the file's goniometer axis turns. Several sweeps can be given (below, Inputs); for now +each is processed alone with `--sweeps separate`. A model is placed (rigid body, or molecular replacement), refined and validated after merging; it does not change the processing decisions ([Models and reference data](RUGNUX_MODEL_AND_REFERENCE.md)). @@ -91,14 +93,24 @@ spot finding or integration ([Re-scaling and re-merging](RUGNUX_ADVANCED.md#re-s ## Inputs Inputs are files, never a directory, read in the order given (a shell glob gives them sorted by -name). Several inputs are read together only as one stills dataset in `--mode mx`: all the files of +name). Several inputs are one stills dataset in `--mode mx`, or several rotation sweeps (below): all the files of one dataset - `rugnux run*.h5` (SACLA), `*.cxi`, `*.xtc` (LCLS) - or several Jungfraujoch/NXmx masters of stills, read as one run: their images one after another, merged together, with the indexing rate reported per master as well. Such masters must share the detector, wavelength, -distance and beam centre. Several inputs must be one format; a rotation sweep (name one frame of a -CBF/marCCD/SMV sweep), any `--mode` other than `mx`, and following a broker take one. What each -format needs is in [What Rugnux reads](RUGNUX_FORMATS.md); serial XFEL stills are read without a -geometry file where their own metadata places the detector +distance and beam centre. Several inputs must be one format; any `--mode` other than `mx`, and +following a broker, take one. + +Several rotation sweeps are told apart from the files: one sweep per CBF/marCCD/SMV file-name +template (the name with its frame number taken out - any frame of a sweep names the whole sweep, so +`rugnux a_*.cbf` is the one sweep `a`), one per rotation master. Only the inputs given are processed; +other sweeps beside them are named in the log. A mix of rotation and stills masters is refused, and +so are sweeps at different wavelengths, `-s`/`-e` and `--force-still` with several sweeps. +`--sweeps separate` processes each sweep alone, as if it were the only input, into +`_s_*` (k = 1, 2, ... in the order given); processing several sweeps as one dataset +(`--sweeps joint`, the default) is not implemented yet and refuses. + +What each format needs is in [What Rugnux reads](RUGNUX_FORMATS.md); serial XFEL stills are read +without a geometry file where their own metadata places the detector ([Serial XFEL inputs](RUGNUX_FORMATS.md#serial-xfel-inputs)). An atomic model and a reference reflection file can be given among the inputs, in any order, @@ -130,6 +142,7 @@ With the input already on disk, the URL is ignored. | `-v, --verbose` | Show the whole log on the terminal in place of the steps; `_log.txt` is written either way | | `--simulate-live ` | Read a stored input as the collection it was while being written: image *i* becomes readable (*i*+1)·`ms` ms after the start (0: all at once). The run takes the live path a broker URL takes, with the same result; for testing the live workflow without a broker. `--mode mx` only | | `--fast-live` | With `--simulate-live` or a broker URL: run as the broker's rugnux live mode does — every decision stands where it was first taken, nothing is re-checked or run again once the sweep is in, the integration radius is fixed, and SIGINT cuts the run to what it has established. A difficult crystal can come out worse | +| `--sweeps joint\|separate` | Several rotation sweeps (see Inputs): `separate` processes each alone into `_s_*`; `joint` (the default), one dataset of all of them, is not implemented yet | | `--no-gpu` | Run on the CPU only, as on a machine without a GPU, even where one is present: CUDA is not touched (nor, on macOS, the Apple GPU that Bragg integration, the model density and the molecular-replacement rotation function otherwise run on) and every GPU step takes its CPU path (`-X FFT` indexes with FFTW and `-X FFBIDX` with its CPU implementation; `-X cuFFT` is refused) | ### Mode diff --git a/rugnux/CMakeLists.txt b/rugnux/CMakeLists.txt index 396d610f5..9f1b96385 100644 --- a/rugnux/CMakeLists.txt +++ b/rugnux/CMakeLists.txt @@ -40,7 +40,8 @@ TARGET_LINK_LIBRARIES(JFJochRugnux JFJochReader JFJochImageAnalysis JFJochPrevie # The files of a run, written by the programs that run one (rugnux, the viewer) from what JFJochRugnux # returns: the library itself writes none. -ADD_LIBRARY(JFJochRugnuxOutput STATIC RugnuxOutputFiles.cpp RugnuxOutputFiles.h RugnuxInputKind.cpp RugnuxInputKind.h) +ADD_LIBRARY(JFJochRugnuxOutput STATIC RugnuxOutputFiles.cpp RugnuxOutputFiles.h RugnuxInputKind.cpp RugnuxInputKind.h + RugnuxSweeps.cpp RugnuxSweeps.h) TARGET_LINK_LIBRARIES(JFJochRugnuxOutput JFJochRugnux JFJochWriter JFJochModelWriter ZLIB::ZLIB) # rugnux is the single offline analysis CLI; --mode picks what it does: mx (the full pipeline, the diff --git a/rugnux/RugnuxSweeps.cpp b/rugnux/RugnuxSweeps.cpp new file mode 100644 index 000000000..1f88a5de4 --- /dev/null +++ b/rugnux/RugnuxSweeps.cpp @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include "RugnuxSweeps.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + // How far two sweeps may state the wavelength apart and still be one dataset: JFJochMultiReader's + // tolerance for the inputs of one stills run. + constexpr double WAVELENGTH_TOLERANCE = 1e-3; // relative + + std::string Lower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); }); + return s; + } +} + +std::string RugnuxFrameTemplate(const std::string &path) { + const std::filesystem::path p(path); + std::string name = p.filename().string(); + size_t end = name.size(); + while (end > 0 && !std::isdigit(static_cast(name[end - 1]))) + end--; + if (end == 0) + return {}; + size_t start = end; + while (start > 0 && std::isdigit(static_cast(name[start - 1]))) + start--; + name.replace(start, end - start, std::string(end - start, '#')); + return (p.parent_path() / name).string(); +} + +RugnuxSweepPlan PlanRugnuxSweeps(const std::vector &inputs, + const std::function &describe_file, + const std::function &describe_sweep) { + RugnuxSweepPlan plan; + if (inputs.size() < 2) + return plan; + + std::vector files; + for (const auto &f: inputs) + files.push_back(describe_file(f)); + const bool any_foreign = std::any_of(files.begin(), files.end(), [](const auto &d) { return d.foreign; }); + const size_t n_frames = std::count_if(files.begin(), files.end(), + [](const auto &d) { return !d.frame_template.empty(); }); + // Inputs of the stills readers, or a mix of frames and masters: read as before, which takes the + // former and refuses the latter. + if (any_foreign || (n_frames != 0 && n_frames != inputs.size())) + return plan; + + if (n_frames == inputs.size()) { + std::set seen; + for (size_t i = 0; i < inputs.size(); i++) + if (seen.insert(files[i].frame_template).second) + plan.sweeps.push_back({inputs[i], files[i].frame_template, true}); + if (plan.sweeps.size() == 1) + return plan; + } else { + // A first input of stills is a stills run, read together as before - and refused there if any + // other input turns. A rotation first input asks every other one. + if (!describe_sweep(inputs[0]).rotation) + return plan; + std::vector rotation{inputs[0]}, stills; + for (size_t i = 1; i < inputs.size(); i++) + (describe_sweep(inputs[i]).rotation ? rotation : stills).push_back(inputs[i]); + if (!stills.empty()) + throw std::runtime_error(fmt::format( + "{} is a rotation sweep and {} a stills collection: rotation sweeps and stills are not " + "processed together; give them in separate runs", rotation.front(), stills.front())); + for (const auto &f: inputs) + plan.sweeps.push_back({f, f, false}); + } + + std::vector wavelength; + for (const auto &s: plan.sweeps) + wavelength.push_back(describe_sweep(s.input).wavelength_A); + for (size_t k = 1; k < plan.sweeps.size(); k++) + if (std::fabs(wavelength[k] - wavelength[0]) > WAVELENGTH_TOLERANCE * wavelength[0]) + throw std::runtime_error(fmt::format( + "{} is at {:.5f} A and {} at {:.5f} A: sweeps at different wavelengths are not processed " + "together; give them in separate runs", plan.sweeps[0].label, wavelength[0], + plan.sweeps[k].label, wavelength[k])); + plan.wavelength_A = wavelength[0]; + return plan; +} + +std::vector RugnuxSiblingSweeps(const RugnuxSweepPlan &plan, const std::vector &extensions) { + namespace fs = std::filesystem; + std::set given, dirs_frames, dirs_masters; + for (const auto &s: plan.sweeps) { + const fs::path p(s.input); + const std::string dir = p.parent_path().empty() ? "." : p.parent_path().string(); + if (s.frames) { + given.insert(fs::path(s.label).filename().string()); + dirs_frames.insert(dir); + } else { + given.insert(p.filename().string()); + dirs_masters.insert(dir); + } + } + auto has_extension = [&](std::string name) { + name = Lower(name); + if (name.size() > 3 && name.ends_with(".gz")) + name.resize(name.size() - 3); + return std::any_of(extensions.begin(), extensions.end(), [&](const auto &e) { return name.ends_with(e); }); + }; + + // template (or master) name -> files, in name order + std::map found; + std::error_code ec; + for (const auto &dir: dirs_frames) + for (const auto &e: fs::directory_iterator(dir, ec)) { + const std::string name = e.path().filename().string(); + if (!e.is_regular_file() || !has_extension(name)) + continue; + const std::string t = fs::path(RugnuxFrameTemplate(e.path().string())).filename().string(); + if (!t.empty() && !given.contains(t)) + found[(fs::path(dir) / t).string()]++; + } + for (const auto &dir: dirs_masters) + for (const auto &e: fs::directory_iterator(dir, ec)) { + const std::string name = e.path().filename().string(); + if (e.is_regular_file() && name.ends_with("_master.h5") && !given.contains(name)) + found[(fs::path(dir) / name).string()] = 0; + } + + std::vector out; + for (const auto &[name, n]: found) + out.push_back(n > 0 ? fmt::format("{} ({} frame{})", name, n, n == 1 ? "" : "s") : name); + return out; +} diff --git a/rugnux/RugnuxSweeps.h b/rugnux/RugnuxSweeps.h new file mode 100644 index 000000000..eec15b1a7 --- /dev/null +++ b/rugnux/RugnuxSweeps.h @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include +#include +#include + +// The rotation sweeps a rugnux command line names, decided from the files in the order given: +// - frame files (CBF, marCCD, SMV): one sweep per file-name template - the name with its last run of +// digits, the frame number, taken out. Any frame of a sweep names the whole sweep, so several frames +// of one template are one sweep; +// - masters: one sweep per rotation master. Several stills masters stay one stills run, as before, and +// a mix of rotation and stills masters is refused. +// Sweeps at different wavelengths are refused. Nothing is planned for a single input, or for inputs only +// the stills readers take (XFEL HDF5, CXI, XTC): those are read as they always were. + +// What the planner needs to know about one input. `frame_template` is empty for anything but a frame +// file; `rotation` and `wavelength_A` are asked only of the input that stands for a sweep. +struct RugnuxInputDescription { + std::string frame_template; + bool foreign = false; // read only by the stills readers (XFEL HDF5, CXI, XTC) + bool rotation = false; + double wavelength_A = 0.0; +}; + +struct RugnuxSweep { + std::string input; // the file the sweep is read from: its first frame given, or its master + std::string label; // the frame template or the master, for messages + bool frames = false; // a sweep of frame files (label is its template) +}; + +struct RugnuxSweepPlan { + // Empty: the inputs are read together as before (one input, a stills run, or a mix the readers refuse). + std::vector sweeps; + double wavelength_A = 0.0; // of every sweep, where there are several +}; + +// `describe_file` says what an input is from its name and contents without reading the sweep (its +// frame template, or whether it is only for the stills readers); `describe_sweep` reads the input that +// stands for a sweep for its rotation and wavelength. Throws std::runtime_error, with a message for the +// user, on a mix of rotation sweeps and stills, or on sweeps at different wavelengths. +RugnuxSweepPlan PlanRugnuxSweeps(const std::vector &inputs, + const std::function &describe_file, + const std::function &describe_sweep); + +// The template of a frame file name, with its directory: the last run of digits in the name replaced by +// '#' characters, one per digit ("/d/x_1_0001.cbf" -> "/d/x_1_####.cbf"). Empty for a name with no digits. +std::string RugnuxFrameTemplate(const std::string &path); + +// The sweeps beside the given ones that were not given: frame templates of `extensions` (lower case, with +// the dot; a trailing ".gz" is looked through) in the directories of the frame sweeps, and other +// "*_master.h5" files beside the given masters - one line each, for the log. Only the inputs given are +// processed; these are named so that a sweep left out by accident shows. +std::vector RugnuxSiblingSweeps(const RugnuxSweepPlan &plan, const std::vector &extensions); diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 0530a68e4..1f58d54fa 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -85,6 +85,7 @@ #include "RugnuxOutputFiles.h" #include "RugnuxConsole.h" #include "RugnuxInputKind.h" +#include "RugnuxSweeps.h" #ifdef __APPLE__ #include "../image_analysis/bragg_integration/BraggIntegrationEngineMetal.h" #endif @@ -100,6 +101,9 @@ constexpr double RELATIVE_B_DEFAULT_DEG = 10.0; // mutually exclusive by construction. enum class RugnuxMode { MX, Azint, Scale, Calibration, Refine }; +// --sweeps: several rotation sweeps as one dataset (joint), or each alone into _s (separate). +enum class RugnuxSweepsMode { Joint, Separate }; + // Azimuthal sectors used by --mode calibration --calibration rings when --azim-phi-bins was not given. // The default of 1 is a plain radial profile, in which a ring has been averaged over every direction so // nothing is left to say where its centre is (RingsFromAzimuthalProfile refuses below 4). Sweeping the @@ -119,6 +123,7 @@ never a directory; a model or reflection file among them is recognised by its co Rotation data (--mode mx, the default; chosen when the file's goniometer axis turns) rugnux -o myrun dataset_master.h5 NXmx / EIGER / Jungfraujoch master file rugnux -o myrun sweep_00001.cbf CBF, marCCD or SMV sweep: name one frame + rugnux -o myrun --sweeps separate a_master.h5 b_master.h5 several sweeps, each alone rugnux -o myrun dataset_master.h5 model.cif with a model (or --model model.cif) rugnux -o myrun x_master.h5 http://host:5232/ follow a broker's collection while it is written A model is placed and refined after merging; it does not change the processing. @@ -166,6 +171,8 @@ Basic options --no-gpu CPU only, even where a GPU is present --no-merge No scaling or merging; write the per-image _process.h5 --write-process-h5 Also write the (large) _process.h5 when merging + --sweeps separate Several rotation sweeps (masters or frame templates): each alone, + into _s_*; processing them as one dataset is to come -v, --verbose Whole log on the terminal (it is always in _log.txt) All options (about 120, for unusual data and diagnosis), each with what it does: @@ -279,7 +286,8 @@ enum { OPT_POLARIZATION, OPT_NO_REFINE_TILT, OPT_REFINE_SCORE_ONLY, - OPT_REFINE_D_MIN + OPT_REFINE_D_MIN, + OPT_SWEEPS }; static option long_options[] = { @@ -303,6 +311,7 @@ static option long_options[] = { {"no-refine-solvent", no_argument, nullptr, OPT_NO_REFINE_SOLVENT}, {"refine-score-only", no_argument, nullptr, OPT_REFINE_SCORE_ONLY}, {"refine-d-min", required_argument, nullptr, OPT_REFINE_D_MIN}, + {"sweeps", required_argument, nullptr, OPT_SWEEPS}, {"export-unmerged", no_argument, nullptr, OPT_EXPORT_UNMERGED}, {"no-export-unmerged", no_argument, nullptr, OPT_NO_EXPORT_UNMERGED}, {"export-unmerged-partials", no_argument, nullptr, OPT_EXPORT_UNMERGED_PARTIALS}, @@ -804,70 +813,27 @@ extern "C" void fftwf_assertion_failed(const char *s, int line, const char *file std::abort(); } -// 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, bool no_gpu) { - // cuFFT plans are reused through the run and destroyed with it, ahead of the device reset in main. - FFTPlanReuse fft_plans; - // The whole invocation, reported at the end and written into the results report. Started before - // anything else so it covers reading the file and setting up as well as the processing. - const auto invocation_start = std::chrono::steady_clock::now(); - RunProvenance provenance; - provenance.command_line = JoinCommandLine(argc, argv); - provenance.gpu_count = get_gpu_count(); -#ifdef __APPLE__ - // Without CUDA, the Bragg integration's integer pixel work runs on the Apple GPU (MXAnalysisWithoutFPGA). - const std::string metal_device = BraggIntegrationEngineMetal::DeviceName(); -#else - const std::string metal_device; -#endif - provenance.gpu_description = get_gpu_description(); +namespace { + // One input's run ended: what exit(EXIT_FAILURE) was where a command line ran one input only. A run of + // several sweeps goes on with the next sweep; RunRugnux returns the code. + struct RugnuxInputFailed { int code; }; + // What Parse and PrepareRugnuxInput return when the run goes on. + constexpr int RUGNUX_CONTINUE = -1; +} - // The terminal shows the run as steps (RugnuxConsole) and every Logger writes to the log file - // instead; with -v the whole log is on the terminal as well. -v is looked for here, before the - // options are parsed, because the parser already logs. +// The command line as parsed, with what RunRugnux set up before it was parsed. The options are members so +// that the code reading and running one input (RugnuxInputRun) reads them by name, as it did when it was +// one function, and so that each sweep of a run of several is run from a copy of its own. +struct RugnuxCli { + bool no_gpu; bool verbose = false; - for (int i = 1; i < argc; i++) - if (std::strcmp(argv[i], "-v") == 0 || std::strcmp(argv[i], "--verbose") == 0) - verbose = true; - console = std::make_shared(verbose); - std::atexit(CloseConsoleOnExit); - // Files with an older time than this are not this run's (the margin covers coarse file times). - const auto files_since = std::filesystem::file_time_type::clock::now() - std::chrono::seconds(2); - auto log_sinks = std::make_shared(); - // What is logged before the log file can be opened - its name comes from -o - waits here. - auto early_log = std::make_shared(10000); - if (verbose) - log_sinks->add_sink(std::make_shared()); - else - log_sinks->add_sink(MakeConsoleSink(console)); - log_sinks->add_sink(early_log); - Logger::RouteAll(log_sinks); - // The HDF5 library prints its own error stack on stderr; every failure it reports reaches the - // reader as an error code anyway, which is turned into a message there. - H5Eset_auto2(H5E_DEFAULT, nullptr, nullptr); - - PrintBanner(*console); - - Logger logger("rugnux"); -#ifdef JFJOCH_USE_CUDA - const char *build = "CUDA build"; -#else - const char *build = "CPU build"; -#endif - logger.Info("rugnux Version {}{} - {}", jfjoch_version(), - jfjoch_git_sha1().empty() ? std::string() - : fmt::format(" (git {} {})", jfjoch_git_sha1().substr(0, 6), jfjoch_git_date()), build); - logger.Info("Build flags: {}", jfjoch_build_cxx_flags().empty() ? "none" : jfjoch_build_cxx_flags()); - logger.Info("Build: {}", jfjoch_build_info(true)); - logger.Info("Command line: {}", provenance.command_line); - logger.Info("GPU: {}", no_gpu ? std::string("not used (--no-gpu) - running on the CPU") - : provenance.gpu_count > 0 ? fmt::format("{} ({} visible)", provenance.gpu_description, provenance.gpu_count) - : !metal_device.empty() ? fmt::format("{} through Metal, for the Bragg integration only - the rest runs on the CPU", metal_device) - : std::string("none visible - running on the CPU")); - RegisterHDF5Filter(); + std::chrono::steady_clock::time_point invocation_start; + RunProvenance provenance; + std::string metal_device; + std::filesystem::file_time_type files_since; + std::shared_ptr log_sinks; + std::shared_ptr early_log; + Logger logger{"rugnux"}; // made after RunRugnux has routed every Logger to the log std::string input_file; std::string output_prefix = "output"; @@ -990,6 +956,62 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { bool simple_stills_flag = false; // --simple-stills: disable the default stills partiality post-refinement std::optional outlier_reject_nsigma; // merge per-observation outlier rejection + std::vector input_files; + std::string broker_url; + std::shared_ptr model; + RugnuxSweepsMode sweeps_mode = RugnuxSweepsMode::Joint; // --sweeps + + RugnuxCli(bool no_gpu_, std::chrono::steady_clock::time_point invocation_start_, RunProvenance provenance_, + std::string metal_device_, std::filesystem::file_time_type files_since_, + std::shared_ptr log_sinks_, + std::shared_ptr early_log_) + : no_gpu(no_gpu_), invocation_start(invocation_start_), provenance(std::move(provenance_)), + metal_device(std::move(metal_device_)), files_since(files_since_), log_sinks(std::move(log_sinks_)), + early_log(std::move(early_log_)) {} + + // The command line: RUGNUX_CONTINUE, or the exit code of a run that ends here. + int Parse(int argc, char **argv); + // Each sweep of the command line, or its one input. + int Run(); +}; + +// One input - one sweep, or one stills run - read, set up and processed. Copied from the command line it +// belongs to, with its own input and output prefix. +struct RugnuxInputRun : RugnuxCli { + explicit RugnuxInputRun(const RugnuxCli &cli) : RugnuxCli(cli) {} + + // What PrepareRugnuxInput sets up and RunPreparedInput runs with. + std::future cufft_warmup, context_warmup; + SimulatedLive hdf5_reader; + SimulatedLive cbf_reader; + SimulatedLive marccd_reader; + SimulatedLive smv_reader; + SimulatedLive xfel_reader; + SimulatedLive xtc_reader; + JFJochStartMessageReader live_reader; + std::unique_ptr multi_reader; + std::unique_ptr broker_feed; + JFJochReader *reader_ptr = nullptr; + std::shared_ptr dataset; + OutputProvenance output_provenance; + DiffractionExperiment experiment; + ProcessConfig config; + std::optional sink; + // The log file of this input, taken out of the log again once it is done. + std::shared_ptr log_file_sink; + + // Reads the input and sets up the experiment and the run's configuration: RUGNUX_CONTINUE, or the + // exit code of a mode that ends here (refine, azint, calibration, scale). + int PrepareRugnuxInput(); + // Runs the input PrepareRugnuxInput set up and writes its files. + int RunPreparedInput(); + int Process() { + const int rc = PrepareRugnuxInput(); + return rc != RUGNUX_CONTINUE ? rc : RunPreparedInput(); + } +}; + +int RugnuxCli::Parse(int argc, char **argv) { if (argc == 1) { print_usage(); exit(EXIT_FAILURE); @@ -1197,6 +1219,16 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { case OPT_REFINE_D_MIN: refine_d_min = parse_number_arg(optarg, "--refine-d-min", logger, 0.1, 100.0); break; + case OPT_SWEEPS: + if (std::strcmp(optarg, "joint") == 0) + sweeps_mode = RugnuxSweepsMode::Joint; + else if (std::strcmp(optarg, "separate") == 0) + sweeps_mode = RugnuxSweepsMode::Separate; + else { + logger.Error("--sweeps expects joint or separate, not {}", optarg); + exit(EXIT_FAILURE); + } + break; case OPT_EXPORT_UNMERGED: export_unmerged = true; break; @@ -1624,8 +1656,8 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { // The inputs, then a broker URL if the last word is one: told apart by the scheme, so that several // stills inputs can be given. - std::vector input_files(argv + optind, argv + argc); - std::string broker_url; + input_files.assign(argv + optind, argv + argc); + broker_url.clear(); if (!input_files.empty() && (input_files.back().starts_with("http://") || input_files.back().starts_with("https://"))) { broker_url = input_files.back(); @@ -1695,7 +1727,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { exit(EXIT_FAILURE); } // Read before anything runs: a file that holds no model ends the run here rather than after it. - std::shared_ptr model; + model.reset(); if (const std::string path = mr_model.empty() ? model_pdb : mr_model; !path.empty()) { try { model = std::make_shared(ReadRugnuxModelFile(path)); @@ -1711,6 +1743,10 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { exit(EXIT_FAILURE); } + return RUGNUX_CONTINUE; +} + +int RugnuxInputRun::PrepareRugnuxInput() { input_file = input_files[0]; // -N defaults to 0 = "use every CPU the run may use" (AvailableCpus); resolve it to a concrete count here so every mode @@ -1735,6 +1771,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { for (const auto &m : early_log->last_raw()) file->log(m); log_sinks->add_sink(file); + log_file_sink = file; } catch (const std::exception &e) { logger.Warning("The log file {} cannot be written: {}", log_file, e.what()); log_file.clear(); @@ -1767,7 +1804,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { data = ReadRugnuxReflectionFile(input_file); } catch (const std::exception &e) { logger.Error("Cannot read the reflections {}: {}", input_file, e.what()); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } console->OnStep(refine_score_only ? "Scoring the model" : "Refining the model"); RefinementSettings settings; @@ -1814,26 +1851,16 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { // Off the critical path, so cuFFT's one-time initialisation overlaps the file open, the mask read // and the beam-stop pre-scan instead of landing inside the first pass. It also times a plan, which // shows whether the CUDA JIT cache works. - auto cufft_warmup = std::async(std::launch::async, [&logger] { LogCudaJitCache(logger, WarmUpCuFFT()); }); + cufft_warmup = std::async(std::launch::async, [this] { LogCudaJitCache(logger, WarmUpCuFFT()); }); // The same for the CUDA contexts of the other cards (the warm-up above creates card 0's), which // the pre-scan does not use: created one after another at start-up, they cost a fifth of a second // per card before the run began. - auto context_warmup = std::async(std::launch::async, create_gpu_contexts, 1); + context_warmup = std::async(std::launch::async, create_gpu_contexts, 1); // 1. Read Input File. An HDF5 master, one PILATUS miniCBF frame, or one marCCD frame - naming // any frame of a sweep reads the whole sweep, which is how these are archived and how a user // thinks of them. console->OnStep("Reading input"); - SimulatedLive hdf5_reader; - SimulatedLive cbf_reader; - SimulatedLive marccd_reader; - SimulatedLive smv_reader; - SimulatedLive xfel_reader; - SimulatedLive xtc_reader; - JFJochStartMessageReader live_reader; - std::unique_ptr multi_reader; - std::unique_ptr broker_feed; - JFJochReader *reader_ptr = nullptr; // Which format this is. Asked INSIDE the try: these open and read the file, and an input the program // cannot read has to come out as the message below, not as an unhandled exception with nothing // printed at all. @@ -1841,13 +1868,13 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { bool input_is_cxi = false, input_is_xtc = false, input_is_panel_table = false; try { // Every input is the format the first one is. - const auto all_inputs = [&input_files](auto can_read) { + const auto all_inputs = [this](auto can_read) { return std::all_of(input_files.begin(), input_files.end(), can_read); }; const auto refuse_mixed = [&]() { logger.Error("The inputs are not all of one format; several inputs must be files of the same " "kind - Jungfraujoch/NXmx masters, SACLA HDF5, CXI or XTC"); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; }; const auto is_foreign = [](const std::string &f) { return JFJochXFELReader::CanReadPanelTable(f) || JFJochXFELReader::CanReadCXI(f) || JFJochXTCReader::CanRead(f); @@ -1859,7 +1886,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { || JFJochSMVReader::CanRead(input_file)) { logger.Error("{} is one frame of a sweep, which names the whole sweep: give just one of its " "frames", input_file); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } if (!all_inputs([&is_foreign](const std::string &f) { return !is_foreign(f) && !JFJochCBFReader::CanRead(f) && !JFJochMarCCDReader::CanRead(f) @@ -1872,7 +1899,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { r->ReadFile(f); } catch (const std::exception &e) { logger.Error("{} is not a Jungfraujoch/NXmx master that can be read: {}", f, e.what()); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } inputs.push_back(std::move(r)); } @@ -1891,7 +1918,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { if (!extra_masks.empty() && !input_is_crystfel && !input_is_panel_table && !input_is_cxi) { logger.Error("--mask-good and --mask-bad are for HDF5/CXI stills (SACLA, CXI or --crystfel-geometry " "input); {} is not one", input_file); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } xfel_reader.ExtraMasks(extra_masks); if (input_is_crystfel) { @@ -1899,7 +1926,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { reader_ptr = &xfel_reader; if (!xfel_reader.HasPhotonEnergy() && !wavelength_A) { logger.Error("Nothing read states the photon energy of {}; give it with --wavelength", input_file); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } logger.Info("Read {} frames from {} files with the CrystFEL geometry {}", xfel_reader.GetNumberOfImages(), input_files.size(), crystfel_geometry); @@ -1913,7 +1940,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { logger.Info("Read {} frames from {} CXI files", xfel_reader.GetNumberOfImages(), input_files.size()); } else if (const auto refusal = JFJochXFELReader::CXIRefusal(input_file)) { logger.Error("{}", *refusal); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } input_is_cbf = !reader_ptr && JFJochCBFReader::CanRead(input_file); input_is_marccd = !reader_ptr && !input_is_cbf && JFJochMarCCDReader::CanRead(input_file); @@ -1925,7 +1952,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { if (gain_per_photon && !reader_ptr && !input_is_xtc && !input_is_smv && !input_is_marccd) { logger.Error("--gain is for input in detector units (SACLA, CXI, XTC, SMV, marCCD or " "--crystfel-geometry input); {} is read in the units it states", input_file); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } if (reader_ptr) { // read above @@ -1962,7 +1989,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { logger.Error("The XTC does not state the detector distance{}{}: give {} (the beam centre is " "measured; with -C the distance is too)", xtc_reader.HasPixelSize() ? "" : ", the pixel size", xtc_reader.HasWavelength() ? "" : ", the wavelength", options); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } reader_ptr = &xtc_reader; logger.Info("Read {} XTC still images from {} streams", xtc_reader.GetNumberOfImages(), @@ -1971,13 +1998,13 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { // The collection is still being written: follow it from the broker. if (mode != RugnuxMode::MX) { logger.Error("Following a collection from the broker is for --mode mx only"); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } const char *token = std::getenv("JUNGFRAUJOCH_HTTP_TOKEN"); if (token == nullptr) { logger.Error("{} does not exist yet, and following it from the broker needs the collection's token " "in the environment variable JUNGFRAUJOCH_HTTP_TOKEN", input_file); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } broker_feed = std::make_unique(broker_url, token); // The broker's event stream starts with the start message: at once on a JUNGFRAU, when the @@ -1989,11 +2016,11 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { const std::string given = std::filesystem::path(input_file).lexically_normal().generic_string(); if (given != master && !given.ends_with("/" + master)) { logger.Error("The broker at {} is writing {}, not {}", broker_feed->Host(), master, input_file); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } if (!start.goniometer.has_value() || !start.goniometer->IsScanning()) { logger.Error("Following a collection from the broker is for rotation data, and {} is not", master); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } live_reader.Open(start, input_file); broker_feed->Attach(live_reader); @@ -2008,7 +2035,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { } } catch (const std::exception &e) { logger.Error("Error reading input file: {}", e.what()); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } JFJochReader &reader = *reader_ptr; @@ -2070,20 +2097,19 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { ? fmt::format(", {:.3f} of them to a photon; intensities and sigmas in photons", adu_per_photon) : std::string(", one to a photon")); - const auto dataset = reader.GetDataset(); + dataset = reader.GetDataset(); if (!dataset) { logger.Error("No experiment dataset found in the input file"); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } if (rotation_indexing_image_count <= 0) { logger.Error("Invalid number of rotation indexing images: {}", rotation_indexing_image_count); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } logger.Info("Loaded dataset from {}", input_file); // Recorded in the reflection files. - OutputProvenance output_provenance; output_provenance.command_line = provenance.command_line; DescribeInput(input_file, output_provenance); output_provenance.collection_date = dataset->arm_date; @@ -2177,7 +2203,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { } } catch (const std::exception &e) { logger.Error("Error reading reference {}: {}", ref_mtz, e.what()); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } } @@ -2188,7 +2214,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { const auto flags = LoadReferenceMtz(free_flags_file, std::nullopt); if (!flags.has_free_flags) { logger.Error("{} carries no R-free flags (no FreeR_flag-like column, no _refln.status)", free_flags_file); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } free_flags_data = flags.reflections; logger.Info("R-free flags from {} (column {}): {} of {} free, taken by the merge where these data match " @@ -2196,7 +2222,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { flags.reflections.size()); } catch (const std::exception &e) { logger.Error("Error reading {}: {}", free_flags_file, e.what()); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } } @@ -2216,7 +2242,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { || input_is_xtc) { logger.Error("--mode scale needs the integrated reflections in a _process.h5; " "a raw image sweep holds none. Run --mode mx on it first."); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } const auto total_images = static_cast(reader.GetNumberOfImages()); const int last_image = (end_image < 0 || end_image >= total_images) ? total_images - 1 : end_image; @@ -2285,7 +2311,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { if (!experiment.GetUnitCell()) { logger.Error("Experiment unit cell not found, cannot update reflection resolution"); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } // The stored reflections have to be in the setting of the cell they are about to be merged in, // or the merge's per-frame cell gate rejects every observation and the run dies far downstream @@ -2327,7 +2353,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { "written before that was recorded)", file_cell->a, file_cell->b, file_cell->c, file_cell->alpha, file_cell->beta, file_cell->gamma); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } } @@ -2351,7 +2377,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { "has, or reprocess the images with --mode mx.", merge_sg->xhm(), uc.a, uc.b, uc.c, uc.alpha, uc.beta, uc.gamma, violation, MAX_METRIC_VIOLATION); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } } @@ -2789,12 +2815,12 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { if (image_stride < 0) { logger.Error("Image stride cannot be negative"); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } if (image_stride == 0) { logger.Error("Image stride cannot be zero"); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } // Round UP: the worker loop walks ordinals until start + ordinal * stride reaches end_image, so a @@ -2809,7 +2835,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { } // 2. Setup Experiment & Components - DiffractionExperiment experiment(dataset->experiment); + experiment = DiffractionExperiment(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. @@ -2845,20 +2871,20 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { } if (modules.empty()) { logger.Error("--module-geometry {} describes fewer than two modules", module_geometry_file); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } for (const auto &m: modules.Get()) if (m.x0 < 0 || m.y0 < 0 || m.x0 + m.width > experiment.GetXPixelsNum() || m.y0 + m.height > experiment.GetYPixelsNum()) { logger.Error("--module-geometry {}: a module lies outside the {} x {} image", module_geometry_file, experiment.GetXPixelsNum(), experiment.GetYPixelsNum()); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } experiment.Modules(modules); logger.Info("--module-geometry: {} modules from {}", modules.size(), module_geometry_file); } catch (const std::exception &e) { logger.Error("Cannot read --module-geometry {}: {}", module_geometry_file, e.what()); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } } if (!experiment.GetModules().empty()) @@ -2927,7 +2953,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { result = process.Run(console.get()); } catch (const std::exception &e) { logger.Error("Processing failed: {}", FailureText(e)); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } g_active_process = nullptr; @@ -3213,7 +3239,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { if (force_still) { if (rotation_indexing) { logger.Error("--force-still conflicts with -R / --single-pass-rotation / --force-rotation-lattice"); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } if (has_goniometer) logger.Info("--force-still: treating the rotation dataset as independent stills"); @@ -3235,7 +3261,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { "measured over frames the sweep never recorded. Process the sweep whole, or select " "a contiguous range with -s/-e. --mode azint and --force-still still take a stride.", image_stride); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } // Scaling and merging are on by default (run_scaling initialised true); --no-merge turns them off @@ -3495,7 +3521,6 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { // 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.provenance = output_provenance; config.start_image = start_image; @@ -3548,7 +3573,6 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { config.refine_solvent = refine_solvent; // 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. - std::optional sink; if (!output_prefix.empty() && (run_scaling ? write_process_h5_flag : true)) config.image_sink = &sink.emplace(output_prefix); config.finalist_ledger = finalist_ledger_flag; @@ -3564,6 +3588,11 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { WarmUpModelValidation(model, static_cast(config.nthreads), logger); simulated_start = std::chrono::steady_clock::now(); + return RUGNUX_CONTINUE; +} + +int RugnuxInputRun::RunPreparedInput() { + JFJochReader &reader = *reader_ptr; Rugnux process(reader, experiment, *dataset->pixel_mask, config); g_active_process = &process; @@ -3602,11 +3631,11 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { provenance); console->Fail(); PrintSummary(*console, doc, output_prefix, files_since, provenance.wall_time_s); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } catch (const std::exception &e) { logger.Error("Processing failed: {}", FailureText(e)); sink.reset(); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } g_active_process = nullptr; @@ -3626,7 +3655,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { logger); } catch (const std::exception &e) { logger.Error("Processing failed: {}", FailureText(e)); - exit(EXIT_FAILURE); + throw RugnuxInputFailed{EXIT_FAILURE}; } } @@ -3770,6 +3799,195 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { return EXIT_SUCCESS; } +int RugnuxCli::Run() { + // The sweeps the inputs are (RugnuxSweeps.h). One input, a stills run and anything the readers refuse + // are read as they were. + const auto describe_file = [this](const std::string &f) { + RugnuxInputDescription d; + if (!crystfel_geometry.empty()) + d.foreign = true; + else if (JFJochCBFReader::CanRead(f) || JFJochMarCCDReader::CanRead(f) || JFJochSMVReader::CanRead(f)) + d.frame_template = RugnuxFrameTemplate(f); + else + d.foreign = JFJochXFELReader::CanReadPanelTable(f) || JFJochXFELReader::CanReadCXI(f) + || JFJochXTCReader::CanRead(f); + return d; + }; + // What a sweep is read as, for its rotation and wavelength: the reader the run itself would take. + const auto read_dataset = [](const std::string &f) -> std::shared_ptr { + if (JFJochCBFReader::CanRead(f)) { + JFJochCBFReader r; + r.ReadFiles(f); + return r.GetDataset(); + } + if (JFJochMarCCDReader::CanRead(f)) { + JFJochMarCCDReader r; + r.ReadFiles(f); + return r.GetDataset(); + } + if (JFJochSMVReader::CanRead(f)) { + JFJochSMVReader r; + r.ReadFiles(f); + return r.GetDataset(); + } + JFJochHDF5Reader r; + r.ReadFile(f); + return r.GetDataset(); + }; + const auto describe_sweep = [&read_dataset](const std::string &f) { + std::shared_ptr dataset; + try { + dataset = read_dataset(f); + } catch (const std::exception &e) { + throw std::runtime_error(fmt::format("{} cannot be read: {}", f, e.what())); + } + RugnuxInputDescription d; + if (dataset) { + const auto g = dataset->experiment.GetGoniometer(); + d.rotation = g.has_value() && g->GetIncrement_deg() != 0.0f; + d.wavelength_A = dataset->experiment.GetWavelength_A(); + } + return d; + }; + RugnuxSweepPlan plan; + try { + plan = PlanRugnuxSweeps(input_files, describe_file, describe_sweep); + } catch (const std::exception &e) { + logger.Error("{}", e.what()); + return EXIT_FAILURE; + } + + if (plan.sweeps.size() < 2) { + // Frames of one sweep: any of them names it. + if (plan.sweeps.size() == 1) + input_files = {plan.sweeps[0].input}; + try { + return RugnuxInputRun(*this).Process(); + } catch (const RugnuxInputFailed &f) { + return f.code; + } + } + + for (size_t k = 0; k < plan.sweeps.size(); k++) + logger.Info("Sweep {}: {}", k + 1, plan.sweeps[k].label); + for (const auto &s: RugnuxSiblingSweeps(plan, {".cbf", ".img", ".mccd", ".osc"})) + logger.Info("Also beside these inputs, not given and so not processed: {}", s); + if (start_image != 0 || end_image >= 0) { + logger.Error("-s / -e select images of one sweep; with several sweeps give each sweep's frames instead"); + return EXIT_FAILURE; + } + if (force_still) { + logger.Error("--force-still processes one rotation sweep as stills; give the sweeps one at a time"); + return EXIT_FAILURE; + } + if (sweeps_mode == RugnuxSweepsMode::Joint) { + logger.Error("{} sweeps: processing them as one dataset is not implemented yet. --sweeps separate " + "processes each sweep alone, into _s_*", plan.sweeps.size()); + return EXIT_FAILURE; + } + + // --sweeps separate: each sweep alone, as if it had been the one input, into _s. + int failed = 0; + for (size_t k = 0; k < plan.sweeps.size(); k++) { + RugnuxInputRun sweep(*this); + sweep.input_files = {plan.sweeps[k].input}; + // Each sweep's report times that sweep; the first also the start-up before it. + if (k > 0) + sweep.invocation_start = std::chrono::steady_clock::now(); + if (!output_prefix.empty()) + sweep.output_prefix = output_prefix + "_s" + std::to_string(k + 1); + logger.Info("Sweep {} of {}: {} into {}_*", k + 1, plan.sweeps.size(), plan.sweeps[k].label, + sweep.output_prefix); + int rc; + try { + rc = sweep.Process(); + } catch (const RugnuxInputFailed &f) { + rc = f.code; + } + if (sweep.log_file_sink) + log_sinks->remove_sink(sweep.log_file_sink); + if (rc != EXIT_SUCCESS) { + logger.Error("Sweep {} ({}) failed", k + 1, plan.sweeps[k].label); + failed++; + } + } + if (failed > 0) + logger.Error("{} of {} sweeps failed", failed, plan.sweeps.size()); + return failed > 0 ? EXIT_FAILURE : EXIT_SUCCESS; +} + + +// 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, bool no_gpu) { + // cuFFT plans are reused through the run and destroyed with it, ahead of the device reset in main. + FFTPlanReuse fft_plans; + // The whole invocation, reported at the end and written into the results report. Started before + // anything else so it covers reading the file and setting up as well as the processing. + const auto invocation_start = std::chrono::steady_clock::now(); + RunProvenance provenance; + provenance.command_line = JoinCommandLine(argc, argv); + provenance.gpu_count = get_gpu_count(); +#ifdef __APPLE__ + // Without CUDA, the Bragg integration's integer pixel work runs on the Apple GPU (MXAnalysisWithoutFPGA). + const std::string metal_device = BraggIntegrationEngineMetal::DeviceName(); +#else + const std::string metal_device; +#endif + provenance.gpu_description = get_gpu_description(); + + // The terminal shows the run as steps (RugnuxConsole) and every Logger writes to the log file + // instead; with -v the whole log is on the terminal as well. -v is looked for here, before the + // options are parsed, because the parser already logs. + bool verbose = false; + for (int i = 1; i < argc; i++) + if (std::strcmp(argv[i], "-v") == 0 || std::strcmp(argv[i], "--verbose") == 0) + verbose = true; + console = std::make_shared(verbose); + std::atexit(CloseConsoleOnExit); + // Files with an older time than this are not this run's (the margin covers coarse file times). + const auto files_since = std::filesystem::file_time_type::clock::now() - std::chrono::seconds(2); + auto log_sinks = std::make_shared(); + // What is logged before the log file can be opened - its name comes from -o - waits here. + auto early_log = std::make_shared(10000); + if (verbose) + log_sinks->add_sink(std::make_shared()); + else + log_sinks->add_sink(MakeConsoleSink(console)); + log_sinks->add_sink(early_log); + Logger::RouteAll(log_sinks); + // The HDF5 library prints its own error stack on stderr; every failure it reports reaches the + // reader as an error code anyway, which is turned into a message there. + H5Eset_auto2(H5E_DEFAULT, nullptr, nullptr); + + PrintBanner(*console); + + Logger logger("rugnux"); +#ifdef JFJOCH_USE_CUDA + const char *build = "CUDA build"; +#else + const char *build = "CPU build"; +#endif + logger.Info("rugnux Version {}{} - {}", jfjoch_version(), + jfjoch_git_sha1().empty() ? std::string() + : fmt::format(" (git {} {})", jfjoch_git_sha1().substr(0, 6), jfjoch_git_date()), build); + logger.Info("Build flags: {}", jfjoch_build_cxx_flags().empty() ? "none" : jfjoch_build_cxx_flags()); + logger.Info("Build: {}", jfjoch_build_info(true)); + logger.Info("Command line: {}", provenance.command_line); + logger.Info("GPU: {}", no_gpu ? std::string("not used (--no-gpu) - running on the CPU") + : provenance.gpu_count > 0 ? fmt::format("{} ({} visible)", provenance.gpu_description, provenance.gpu_count) + : !metal_device.empty() ? fmt::format("{} through Metal, for the Bragg integration only - the rest runs on the CPU", metal_device) + : std::string("none visible - running on the CPU")); + RegisterHDF5Filter(); + + RugnuxCli cli(no_gpu, invocation_start, provenance, metal_device, files_since, log_sinks, early_log); + if (const int rc = cli.Parse(argc, argv); rc != RUGNUX_CONTINUE) + return rc; + return cli.Run(); +} + int main(int argc, char **argv) { try { // Before anything creates a CUDA context, which fixes the flags for the device. A thread diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b44df7a59..63599b77e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -212,6 +212,7 @@ ADD_EXECUTABLE(jfjoch_test WriteReflectionsTest.cpp RugnuxResultFilesTest.cpp RugnuxInputKindTest.cpp + RugnuxSweepsTest.cpp UnitCellTest.cpp CCTest.cpp MultiLatticeSearchTest.cpp diff --git a/tests/RugnuxSweepsTest.cpp b/tests/RugnuxSweepsTest.cpp new file mode 100644 index 000000000..a46541304 --- /dev/null +++ b/tests/RugnuxSweepsTest.cpp @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include + +#include +#include +#include +#include + +#include "../rugnux/RugnuxSweeps.h" + +namespace { + +// Inputs described by name alone: "