From 4292c990ea330a4c194d8c579362839a33ca3489 Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 09:36:53 +0200 Subject: [PATCH 01/47] viewer: open WSL/UNC paths by converting to native separators Qt's QFileDialog returns '/'-separated paths even on Windows, so a UNC path like \\wsl.localhost\Ubuntu\... arrives as //wsl.localhost/Ubuntu/... and H5Fopen (via the Win32 file layer) does not recognise the forward-slash form as UNC, while C:/... still works - exactly the reported symptom. Normalise the file-dialog result with QDir::toNativeSeparators before opening. No-op on Linux. Co-Authored-By: Claude Opus 4.8 --- viewer/JFJochViewerMenu.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/viewer/JFJochViewerMenu.cpp b/viewer/JFJochViewerMenu.cpp index ef60b2b4..e69ade56 100644 --- a/viewer/JFJochViewerMenu.cpp +++ b/viewer/JFJochViewerMenu.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -155,7 +156,10 @@ void JFJochViewerMenu::openSelected() { ); if (!fileName.isEmpty()) - emit fileOpenSelected(fileName, 0, 1, false); + // Qt returns '/'-separated paths even on Windows, but HDF5's H5Fopen needs native separators + // to recognise a UNC path (\\host\share, e.g. the WSL2 filesystem): '//host/share' is rejected, + // while 'C:/...' still works, which is exactly the reported symptom. No-op on Linux. + emit fileOpenSelected(QDir::toNativeSeparators(fileName), 0, 1, false); } void JFJochViewerMenu::closeSelected() { -- 2.54.0 From 88589514190525371c821809f2cfec2a6eb1120f Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 09:39:46 +0200 Subject: [PATCH 02/47] rugnux: allow absolute output paths for offline processing The FilePrefix/CheckPath guard forbids absolute paths and '..' traversal so a remote client cannot make the broker/writer write outside their run directory. Offline processing (rugnux -o, and the viewer writing next to the input file) supplies its own trusted local path, so that guard should not apply to it. Add a trusted opt-in that leaves the broker/writer path untouched: - DatasetSettings/DiffractionExperiment::FilePrefixTrusted() sets the prefix without CheckPath (FilePrefix() = CheckPath + FilePrefixTrusted). - FileWriter gains a trusted_path ctor flag that skips its own CheckPath. - Rugnux uses both. Broker/writer never set them, so their behaviour is identical; this only widens what the offline CLI/viewer may write. Previously 'rugnux -o /abs/path' threw ('Path cannot start with slash') while building the _process.h5; the .mtz/.cif already bypassed the guard. Co-Authored-By: Claude Opus 4.8 --- common/DatasetSettings.cpp | 8 +++++++- common/DatasetSettings.h | 1 + common/DiffractionExperiment.cpp | 5 +++++ common/DiffractionExperiment.h | 1 + rugnux/Rugnux.cpp | 7 +++++-- writer/FileWriter.cpp | 7 +++++-- writer/FileWriter.h | 7 ++++++- 7 files changed, 30 insertions(+), 6 deletions(-) diff --git a/common/DatasetSettings.cpp b/common/DatasetSettings.cpp index 8e8e8f27..912193d0 100644 --- a/common/DatasetSettings.cpp +++ b/common/DatasetSettings.cpp @@ -77,8 +77,14 @@ DatasetSettings &DatasetSettings::DetectorDistance_mm(float input) { } DatasetSettings &DatasetSettings::FilePrefix(std::string input) { - CheckPath(input); + CheckPath(input); // multi-user guard: no absolute path, no '..' traversal + return FilePrefixTrusted(std::move(input)); +} +DatasetSettings &DatasetSettings::FilePrefixTrusted(std::string input) { + // Offline/local callers (rugnux, the viewer's processing) supply their own output path, so the + // CheckPath guard applied by FilePrefix() is deliberately skipped here - an absolute path is + // allowed. Not reachable from the broker/writer remote-input path. if ((input.find("_master.h5") == input.length() - 10) && (input.length() > 10)) file_prefix = input.substr(0, input.length() - 10); else diff --git a/common/DatasetSettings.h b/common/DatasetSettings.h index d4854871..17f1f974 100644 --- a/common/DatasetSettings.h +++ b/common/DatasetSettings.h @@ -78,6 +78,7 @@ public: DatasetSettings& BeamY_pxl(float input); DatasetSettings& DetectorDistance_mm(float input); DatasetSettings& FilePrefix(std::string input); + DatasetSettings& FilePrefixTrusted(std::string input); // offline/local: no CheckPath guard (absolute allowed) DatasetSettings& Compression(CompressionAlgorithm input); DatasetSettings& SetUnitCell(const std::optional &cell); DatasetSettings& SpaceGroupNumber(std::optional input); diff --git a/common/DiffractionExperiment.cpp b/common/DiffractionExperiment.cpp index 7e16f1c7..d7b0f661 100644 --- a/common/DiffractionExperiment.cpp +++ b/common/DiffractionExperiment.cpp @@ -139,6 +139,11 @@ DiffractionExperiment &DiffractionExperiment::FilePrefix(std::string input) { return *this; } +DiffractionExperiment &DiffractionExperiment::FilePrefixTrusted(std::string input) { + dataset.FilePrefixTrusted(std::move(input)); + return *this; +} + DiffractionExperiment &DiffractionExperiment::UseInternalPacketGenerator(bool input) { detector_settings.InternalGeneratorEnable(input); return *this; diff --git a/common/DiffractionExperiment.h b/common/DiffractionExperiment.h index 01ce9dd4..98167523 100644 --- a/common/DiffractionExperiment.h +++ b/common/DiffractionExperiment.h @@ -125,6 +125,7 @@ public: DiffractionExperiment& BeamY_pxl(float input); DiffractionExperiment& DetectorDistance_mm(float input); DiffractionExperiment& FilePrefix(std::string input); + DiffractionExperiment& FilePrefixTrusted(std::string input); // offline/local: no CheckPath guard (absolute allowed) DiffractionExperiment& Compression(CompressionAlgorithm input); DiffractionExperiment& SetUnitCell(const std::optional &cell); DiffractionExperiment& SpaceGroupNumber(std::optional input); diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index 6063722f..8c1d3ad8 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -103,7 +103,9 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) { experiment_.BitDepthImage(32).PixelSigned(true); experiment_.Mode(DetectorMode::Standard); experiment_.OverwriteExistingFiles(true); - experiment_.FilePrefix(config_.output_prefix.empty() ? "output" : config_.output_prefix); + // Offline processing: the output prefix is a local path the operator chose (rugnux -o or the + // viewer's "next to the input file"), so use the trusted setter that permits an absolute path. + experiment_.FilePrefixTrusted(config_.output_prefix.empty() ? "output" : config_.output_prefix); experiment_.SetFileWriterFormat(FileWriterFormat::NXmxLegacy); experiment_.ImagesPerTrigger(images_to_process); experiment_.NumTriggers(1); @@ -155,7 +157,8 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) { std::unique_ptr writer; if (write_files && config_.write_process_h5) - writer = std::make_unique(start_message); + writer = std::make_unique(start_message, /*check_overwrite_at_start=*/true, + /*trusted_path=*/true); logger.Info("Processing {} images (range {}-{}, stride {}) using {} threads [{}]", images_to_process, start_image, end_image, config_.stride, config_.nthreads, diff --git a/writer/FileWriter.cpp b/writer/FileWriter.cpp index ff4ebf1e..1c5d6508 100644 --- a/writer/FileWriter.cpp +++ b/writer/FileWriter.cpp @@ -11,7 +11,7 @@ #include "../preview/JFJochTIFF.h" #include "JFJochDecompress.h" -FileWriter::FileWriter(const StartMessage &request, bool check_overwrite_at_start) +FileWriter::FileWriter(const StartMessage &request, bool check_overwrite_at_start, bool trusted_path) : start_message(request) { if (start_message.file_format) format = start_message.file_format.value(); @@ -19,7 +19,10 @@ FileWriter::FileWriter(const StartMessage &request, bool check_overwrite_at_star if (start_message.images_per_file <= 0) start_message.images_per_file = default_images_per_file; - CheckPath(start_message.file_prefix); + // trusted_path skips the multi-user guard so an offline caller can write to an absolute path; + // MakeDirectory still runs (it handles absolute prefixes). See the constructor comment in the header. + if (!trusted_path) + CheckPath(start_message.file_prefix); MakeDirectory(start_message.file_prefix); if (check_overwrite_at_start) CheckOutputFilesAvailable(); diff --git a/writer/FileWriter.h b/writer/FileWriter.h index f4855921..ae3e5bd4 100644 --- a/writer/FileWriter.h +++ b/writer/FileWriter.h @@ -38,7 +38,12 @@ public: // acquisition arms. The ZeroMQ pusher is fire-and-forget with no back-channel, // so it must pass false: the writer then falls back to writing .tmp files and // failing at the final rename (see docs/JFJOCH_WRITER.md). - explicit FileWriter(const StartMessage &request, bool check_overwrite_at_start = true); + // trusted_path: the caller supplied the output prefix itself (offline rugnux / the viewer's + // processing), so the multi-user CheckPath guard - which forbids absolute paths and '..' traversal + // for remotely-supplied prefixes - is skipped, allowing an absolute output path. The broker/writer + // never set it, so their behaviour is unchanged. + explicit FileWriter(const StartMessage &request, bool check_overwrite_at_start = true, + bool trusted_path = false); void Write(const DataMessage& msg); void WriteTIFF(const DataMessage& msg); void WriteHDF5(const DataMessage& msg); -- 2.54.0 From 343af8deedb4cb9a211da30918413e39bc7743d0 Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 09:40:28 +0200 Subject: [PATCH 03/47] viewer: write processing output next to the input file The New-job dialog seeded the output prefix from QFileInfo::completeBaseName(), which drops the directory, so _process.h5/.cif/.mtz landed in the viewer's working directory (wherever it was installed) instead of beside the dataset. Seed it with the input file's absolute directory instead; the field stays editable. Relies on the trusted output-path support so the absolute prefix is accepted by the writer. Co-Authored-By: Claude Opus 4.8 --- viewer/windows/JFJochProcessingJobsWindow.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/viewer/windows/JFJochProcessingJobsWindow.cpp b/viewer/windows/JFJochProcessingJobsWindow.cpp index f6e56d2e..8bf569ae 100644 --- a/viewer/windows/JFJochProcessingJobsWindow.cpp +++ b/viewer/windows/JFJochProcessingJobsWindow.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -140,8 +141,14 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec auto *save = new QCheckBox("Save _process.h5", &dlg); save->setChecked(true); - const QString stem = QFileInfo(inputs.file).completeBaseName().remove(QStringLiteral("_master")); - auto *prefix = new QLineEdit(QStringLiteral("%1_run%2").arg(stem).arg(job_counter_ + 1), &dlg); + // Default the output next to the input file, not the viewer's working directory (the viewer starts + // wherever it was installed). This is an absolute path; Rugnux writes it via the trusted setter, + // and toNativeSeparators keeps a UNC/Windows path openable afterwards. + const QFileInfo fi(inputs.file); + const QString stem = fi.completeBaseName().remove(QStringLiteral("_master")); + const QString default_prefix = QDir::toNativeSeparators( + QStringLiteral("%1/%2_run%3").arg(fi.absolutePath(), stem).arg(job_counter_ + 1)); + auto *prefix = new QLineEdit(default_prefix, &dlg); // Rotation-vs-stills mode (rotation indexing + partiality + rot3d) is set in the settings panel via // "Process as stills"; the dialog only collects run options. Scaling applies to MX full analysis. -- 2.54.0 From 9b1ab02c41e89fe54a94df5a5097506ea70c5577 Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 09:42:23 +0200 Subject: [PATCH 04/47] viewer: control _process.h5 and merged .mtz/.cif output independently The 'Save _process.h5' checkbox drove output_prefix, so unchecking it also suppressed the merged .mtz/.cif, and there was no way to run for the ISa/R-meas numbers only. Split it into two checkboxes wired to independent flags: - Save _process.h5 -> ProcessConfig::write_process_h5 (also drives the viewer snapshot, so unchecking it means the viewer is not updated with spots/results) - Write merged .mtz/.cif -> new ProcessConfig::write_merged output_prefix is set when either is wanted; both off = process for stats only. Scaling/merge statistics (hence the ISa/R-meas window) are independent of file writing, so they show in every case. RugnuxCommandLine now emits --write-process-h5 so a copied command matches the GUI's choice. Co-Authored-By: Claude Opus 4.8 --- rugnux/Rugnux.cpp | 2 +- rugnux/Rugnux.h | 5 ++++ rugnux/RugnuxCommandLine.cpp | 5 ++++ viewer/windows/JFJochProcessingJobsWindow.cpp | 25 ++++++++++++++----- viewer/windows/JFJochProcessingJobsWindow.h | 3 ++- 5 files changed, 32 insertions(+), 8 deletions(-) diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index 8c1d3ad8..b7179a02 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -831,7 +831,7 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) { result.merge_statistics = sm.statistics; result.has_reference = !config_.reference_data.empty(); - if (result.consensus_cell && write_files) { + if (result.consensus_cell && write_files && config_.write_merged) { phase("Writing reflections"); WriteReflections(sm.merged, *result.consensus_cell, experiment_, sm.statistics, result.error_model_isa > 0 ? fmt::format("{:.2f}", result.error_model_isa) : "?", diff --git a/rugnux/Rugnux.h b/rugnux/Rugnux.h index 69e9a059..efa6f41e 100644 --- a/rugnux/Rugnux.h +++ b/rugnux/Rugnux.h @@ -46,6 +46,11 @@ struct ProcessConfig { // (the .mtz/.cif is the wanted output and the h5 is large) unless --write-process-h5 is given. bool write_process_h5 = true; + // Write the merged reflections (.mtz/.cif). Defaults true (the CLI always writes them when + // merging); the viewer can turn them off independently of the _process.h5 (e.g. a quick run that + // only wants the ISa/R-meas numbers). Only relevant to FullAnalysis with scaling. + bool write_merged = true; + SpotFindingSettings spot_finding; // FullAnalysis spot finding // Rotation indexing (FullAnalysis) diff --git a/rugnux/RugnuxCommandLine.cpp b/rugnux/RugnuxCommandLine.cpp index 18408bbe..3ea8f241 100644 --- a/rugnux/RugnuxCommandLine.cpp +++ b/rugnux/RugnuxCommandLine.cpp @@ -127,6 +127,11 @@ std::string RugnuxCommandLine(const ProcessConfig &config, args.emplace_back("-A"); if (sc.GetRefineB()) args.emplace_back("-B"); + // 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"); } diff --git a/viewer/windows/JFJochProcessingJobsWindow.cpp b/viewer/windows/JFJochProcessingJobsWindow.cpp index 8bf569ae..0f2f2c66 100644 --- a/viewer/windows/JFJochProcessingJobsWindow.cpp +++ b/viewer/windows/JFJochProcessingJobsWindow.cpp @@ -138,8 +138,15 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec threads->setRange(1, 256); threads->setValue(default_threads()); - auto *save = new QCheckBox("Save _process.h5", &dlg); - save->setChecked(true); + // Two independent outputs: the large per-image _process.h5 (updates the viewer with spots/results) + // and the small merged .mtz/.cif. Either can be turned off; the ISa/R-meas window is shown + // regardless (it needs only the in-memory merge statistics), so a stats-only run turns both off. + auto *save_h5 = new QCheckBox("Save _process.h5 (per-image results; updates viewer)", &dlg); + save_h5->setChecked(true); + + auto *save_merged = new QCheckBox("Write merged .mtz/.cif", &dlg); + save_merged->setChecked(true); + save_merged->setEnabled(!azint); // no merged output in azimuthal-integration mode // Default the output next to the input file, not the viewer's working directory (the viewer starts // wherever it was installed). This is an absolute path; Rugnux writes it via the trusted setter, @@ -160,7 +167,8 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec form->addRow("Start image", start_image); form->addRow("End image", end_image); form->addRow("Threads", threads); - form->addRow(save); + form->addRow(save_h5); + form->addRow(save_merged); form->addRow("Output prefix", prefix); form->addRow(scaling); @@ -189,7 +197,8 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec spec.start_image = start_image->value(); spec.end_image = end_image->value(); spec.threads = threads->value(); - spec.save = save->isChecked(); + spec.save_h5 = save_h5->isChecked(); + spec.save_merged = save_merged->isEnabled() && save_merged->isChecked(); spec.prefix = prefix->text(); spec.scaling = scaling->isEnabled() && scaling->isChecked(); } @@ -202,7 +211,11 @@ ProcessConfig JFJochProcessingJobsWindow::buildConfig(const JobSpec &spec, const config.nthreads = spec.threads; config.start_image = spec.start_image; config.end_image = spec.end_image > 0 ? spec.end_image : -1; // 0 => to the end - config.output_prefix = spec.save ? spec.prefix.toStdString() : std::string(); + // Files land at output_prefix; leave it empty (write nothing, stats only) when neither output is + // wanted. The two flags then select which files are actually written there. + config.output_prefix = (spec.save_h5 || spec.save_merged) ? spec.prefix.toStdString() : std::string(); + config.write_process_h5 = spec.save_h5; + config.write_merged = spec.save_merged; config.spot_finding = inputs.spot_finding; if (spec.mode == ProcessMode::FullAnalysis) { // Rotation indexing follows the panel's "Process as stills" (= the experiment's indexing @@ -256,7 +269,7 @@ void JFJochProcessingJobsWindow::newJob(bool azint) { JobInfo info; info.id = id; info.label = label; - if (spec.save) + if (spec.save_h5) info.snapshot_path = QString::fromStdString(config.output_prefix) + "_process.h5"; const int row = table_->rowCount(); diff --git a/viewer/windows/JFJochProcessingJobsWindow.h b/viewer/windows/JFJochProcessingJobsWindow.h index 6857fdd0..f24e21db 100644 --- a/viewer/windows/JFJochProcessingJobsWindow.h +++ b/viewer/windows/JFJochProcessingJobsWindow.h @@ -71,7 +71,8 @@ private: int start_image = 0; int end_image = 0; // 0 == to the end int threads = 4; - bool save = true; + bool save_h5 = true; // write the per-image _process.h5 (also drives the viewer snapshot) + bool save_merged = true; // write the merged .mtz/.cif QString prefix; bool rotation = false; int rotation_images = 30; // images used to find the rotation lattice (first pass) -- 2.54.0 From 228841870d4576afbce90d2fbd8a22c3ece55c12 Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 09:51:03 +0200 Subject: [PATCH 05/47] rugnux: honour an absolute -o output prefix configure_offline_output still routed the -o prefix through FilePrefix (the CheckPath guard), so 'rugnux -o /abs/path' threw 'Path cannot start with slash' before processing even though the writer path already supported it. Use the trusted setter here too. Completes the offline absolute-path support (the writer and Rugnux::Run already skip the guard); the .mtz/.cif and _process.h5 now land at an absolute -o. The --scale path shares this function and writes via WriteReflections (no FileWriter), so it is covered as well. Co-Authored-By: Claude Opus 4.8 --- tools/rugnux_cli.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/rugnux_cli.cpp b/tools/rugnux_cli.cpp index cb422877..01ea9918 100644 --- a/tools/rugnux_cli.cpp +++ b/tools/rugnux_cli.cpp @@ -378,7 +378,9 @@ std::optional parse_lattice_arg(const char *arg) { // path and the full-analysis path (each then sets its own space group and images-per-trigger). void configure_offline_output(DiffractionExperiment &experiment, const std::string &output_prefix) { experiment.BitDepthImage(32).Compression(CompressionAlgorithm::BSHUF_LZ4); - experiment.FilePrefix(output_prefix); + // Offline CLI: the operator chose the output path, so allow an absolute -o (the multi-user guard + // that FilePrefix() applies is only for remotely-supplied prefixes in the broker/writer). + experiment.FilePrefixTrusted(output_prefix); experiment.Mode(DetectorMode::Standard); // full image analysis experiment.PixelSigned(true); experiment.OverwriteExistingFiles(true); -- 2.54.0 From 0ed943dd4da2b90f372918f6fefe6af6f04d0784 Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 09:52:25 +0200 Subject: [PATCH 06/47] viewer: show the space group in the merge-statistics window The de-novo space-group search (SearchSpaceGroup) already ran inside Rugnux but only its chosen number survived on ProcessResult, and the viewer dropped even that. Carry the structured SearchSpaceGroupResult (point group + ranked candidate scores) on ProcessResult and thread it into JFJochMergeStatsWindow, which now shows a 'Space group' hero card (the final group, searched or fixed) plus a compact table of candidate groups and their absence scores. The library no longer renders the search to text (it used to embed it in merge_statistics_text); rugnux_cli formats it for stdout instead, so the CLI keeps its text table while the viewer draws a proper table and does not spew it to stdout. Also surface a user-fixed space group on the result so the card shows it too. Co-Authored-By: Claude Opus 4.8 --- rugnux/Rugnux.cpp | 7 ++- rugnux/Rugnux.h | 10 +++- tools/rugnux_cli.cpp | 6 ++ viewer/windows/JFJochMergeStatsWindow.cpp | 57 ++++++++++++++++++- viewer/windows/JFJochMergeStatsWindow.h | 9 ++- viewer/windows/JFJochProcessingJobsWindow.cpp | 5 +- viewer/windows/JFJochProcessingJobsWindow.h | 4 ++ 7 files changed, 90 insertions(+), 8 deletions(-) diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index b7179a02..1d88aa30 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -669,7 +669,6 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) { if (end_msg.rotation_lattice_type.has_value()) sg_opts.lattice_system = end_msg.rotation_lattice_type->crystal_system; auto sg_search = SearchSpaceGroup(sm.merged, sg_opts); - stats_text << SearchSpaceGroupResultToText(sg_search) << "\n\n"; // Miller-index reindex under a change of basis: (h,k,l)_conv = reindex * (h,k,l)_prim. const auto reindex_hkl = [](auto &r, const gemmi::Mat33 &m) { @@ -723,8 +722,6 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) { SearchSpaceGroupOptions o2 = sg_opts; o2.lattice_system = cand.system; const auto s2 = SearchSpaceGroup(merged_c, o2); - stats_text << "Pseudo-symmetry re-test in the metric candidate's conventional setting:\n" - << SearchSpaceGroupResultToText(s2) << "\n\n"; if (s2.best_space_group.has_value() && s2.best_space_group->number > 1) { // The centring cannot be confirmed from absences here (integrated in the primitive // cell, so the centring-absent reflections do not exist) - it is metric-determined. @@ -785,6 +782,10 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) { phase("Re-scaling in space group " + sg.short_name()); sm = scale_and_merge(sg.short_name(), false); } + result.space_group_search = sg_search; + } else { + // A space group was fixed by the user; surface it so the viewer/CLI can still show it. + result.space_group_number = experiment_.GetSpaceGroupNumber(); } // Ice-ring CC1/2 mask: a hexagonal-ice ring whose merged half-set CC1/2 collapses well below its diff --git a/rugnux/Rugnux.h b/rugnux/Rugnux.h index efa6f41e..58a7f167 100644 --- a/rugnux/Rugnux.h +++ b/rugnux/Rugnux.h @@ -17,6 +17,7 @@ #include "../image_analysis/spot_finding/SpotFindingSettings.h" #include "../image_analysis/scale_merge/Merge.h" // MergeStatistics #include "../image_analysis/scale_merge/TwinningAnalysis.h" // TwinningAnalysisResult +#include "../image_analysis/scale_merge/SearchSpaceGroup.h" // SearchSpaceGroupResult class JFJochHDF5Reader; @@ -93,9 +94,14 @@ struct ProcessResult { // Twinning analysis of the final merged intensities (l_test_pairs == 0 when not computed). TwinningAnalysisResult twinning; - // Space group determined by the search (when the user did not fix one), used for the final - // re-scale/merge and written to the master file. + // Space group used for the final re-scale/merge and written to the master file: determined by the + // search when the user did not fix one, otherwise the fixed group. std::optional space_group_number; + + // Structured result of the de-novo space-group search (point group + ranked candidate scores), + // populated only when a search ran (no space group was fixed). The library no longer renders it to + // text - the CLI formats it for stdout, the viewer draws it as a table. + std::optional space_group_search; }; // Callbacks for progress and live results. Methods may be called from worker threads, so an diff --git a/tools/rugnux_cli.cpp b/tools/rugnux_cli.cpp index 01ea9918..6272f6ae 100644 --- a/tools/rugnux_cli.cpp +++ b/tools/rugnux_cli.cpp @@ -27,6 +27,7 @@ #include "../image_analysis/scale_merge/RotationScaleMerge.h" #include "../image_analysis/scale_merge/ResolutionCutoff.h" #include "../image_analysis/scale_merge/TwinningAnalysis.h" +#include "../image_analysis/scale_merge/SearchSpaceGroup.h" #include "../rugnux/Rugnux.h" // Default rot3d per-frame scale-G smoothing range (XDS DELPHI-like), in degrees of rotation. @@ -1291,6 +1292,11 @@ int main(int argc, char **argv) { } g_active_process = nullptr; + // The space-group search is rendered here (not in the library) so the viewer does not emit it on + // stdout and the CLI owns the format. + if (result.space_group_search.has_value()) + std::cout << std::endl << SearchSpaceGroupResultToText(*result.space_group_search) << std::endl; + if (!result.merge_statistics_text.empty()) std::cout << std::endl << result.merge_statistics_text << std::endl; diff --git a/viewer/windows/JFJochMergeStatsWindow.cpp b/viewer/windows/JFJochMergeStatsWindow.cpp index 78091240..02dd4018 100644 --- a/viewer/windows/JFJochMergeStatsWindow.cpp +++ b/viewer/windows/JFJochMergeStatsWindow.cpp @@ -12,12 +12,15 @@ #include #include #include +#include #include #include #include #include #include +#include "gemmi/symmetry.hpp" // find_spacegroup_by_number + #include "../charts/JFJochSimpleChartView.h" #include "../widgets/ToolbarIcons.h" @@ -62,7 +65,10 @@ namespace { JFJochMergeStatsWindow::JFJochMergeStatsWindow(const QString &title, const MergeStatistics &stats, double isa, bool has_reference, - const TwinningAnalysisResult &twinning, QWidget *parent) + const TwinningAnalysisResult &twinning, + std::optional space_group_number, + const std::optional &space_group_search, + QWidget *parent) : QWidget(parent, Qt::Window), stats_(stats), has_reference_(has_reference) { setWindowTitle("Merge statistics — " + title); setAttribute(Qt::WA_DeleteOnClose); @@ -87,8 +93,57 @@ JFJochMergeStatsWindow::JFJochMergeStatsWindow(const QString &title, const Merge hero->addWidget(MakeCard(num(multiplicity, 1), "Multiplicity", this)); if (has_reference_) hero->addWidget(MakeCard(pct(o.cc_ref * 100.0), "CCref", this)); + if (space_group_number.has_value()) { + const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_number(static_cast(*space_group_number)); + const QString sg_name = sg ? QString::fromStdString(sg->short_name()) + : QString::number(*space_group_number); + hero->addWidget(MakeCard(sg_name, "Space group", this)); + } layout->addLayout(hero); + // De-novo space-group search: the point group + the ranked candidates it scored (only present when + // the space group was not fixed by the user). A summary line above a compact table. + if (space_group_search.has_value() && !space_group_search->candidates.empty()) { + const auto &sgs = *space_group_search; + QString summary = "Point group " + QString::fromStdString(sgs.point_group_hm.empty() ? "?" : sgs.point_group_hm); + if (!sgs.alternatives.empty()) { + QStringList alts; + for (const auto &alt : sgs.alternatives) + alts << QString::fromStdString(alt.short_name()); + summary += " · indistinguishable from these data: " + alts.join(", "); + } + auto *sgLabel = new QLabel(summary, this); + sgLabel->setStyleSheet("color: gray;"); + layout->addWidget(sgLabel); + + auto *sgTable = new QTableWidget(this); + sgTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + sgTable->verticalHeader()->setVisible(false); + const QStringList sgHeaders{"Space group", "Absent", "Viol.", "⟨I/σ⟩ abs", "⟨I/σ⟩ pres", "Consistent"}; + sgTable->setColumnCount(sgHeaders.size()); + sgTable->setHorizontalHeaderLabels(sgHeaders); + sgTable->setRowCount(static_cast(sgs.candidates.size())); + for (int i = 0; i < static_cast(sgs.candidates.size()); ++i) { + const auto &c = sgs.candidates[i]; + int col = 0; + auto set = [&](const QString &t, bool leftAlign = false) { + auto *it = new QTableWidgetItem(t); + it->setTextAlignment((leftAlign ? Qt::AlignLeft : Qt::AlignRight) | Qt::AlignVCenter); + if (c.selected) { QFont f = it->font(); f.setBold(true); it->setFont(f); } + sgTable->setItem(i, col++, it); + }; + set(QString::fromStdString(c.space_group.short_name()) + (c.selected ? " *" : ""), true); + set(QString::number(c.absent_observed)); + set(QString::number(c.absent_violations)); + set(QString::number(c.absent_mean_i_over_sigma, 'f', 2)); + set(QString::number(c.present_mean_i_over_sigma, 'f', 2)); + set(c.consistent ? QStringLiteral("yes") : QStringLiteral("no")); + } + sgTable->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + sgTable->setMaximumHeight(160); // a secondary detail — keep the shells plot the focus + layout->addWidget(sgTable); + } + // --- Twinning test (Padilla-Yeates L-test + second moment) --- // A red-outlined banner flags suspected twinning; otherwise a quiet grey "all clear". if (twinning.l_test_pairs > 0) { diff --git a/viewer/windows/JFJochMergeStatsWindow.h b/viewer/windows/JFJochMergeStatsWindow.h index a95eb4fe..b3c574e2 100644 --- a/viewer/windows/JFJochMergeStatsWindow.h +++ b/viewer/windows/JFJochMergeStatsWindow.h @@ -5,8 +5,12 @@ #include +#include +#include + #include "../../image_analysis/scale_merge/Merge.h" // MergeStatistics #include "../../image_analysis/scale_merge/TwinningAnalysis.h" // TwinningAnalysisResult +#include "../../image_analysis/scale_merge/SearchSpaceGroup.h" // SearchSpaceGroupResult class QComboBox; class QLabel; @@ -23,7 +27,10 @@ class JFJochMergeStatsWindow : public QWidget { public: JFJochMergeStatsWindow(const QString &title, const MergeStatistics &stats, double isa, bool has_reference, - const TwinningAnalysisResult &twinning, QWidget *parent = nullptr); + const TwinningAnalysisResult &twinning, + std::optional space_group_number, + const std::optional &space_group_search, + QWidget *parent = nullptr); private: MergeStatistics stats_; diff --git a/viewer/windows/JFJochProcessingJobsWindow.cpp b/viewer/windows/JFJochProcessingJobsWindow.cpp index 0f2f2c66..73bb0a80 100644 --- a/viewer/windows/JFJochProcessingJobsWindow.cpp +++ b/viewer/windows/JFJochProcessingJobsWindow.cpp @@ -364,7 +364,8 @@ void JFJochProcessingJobsWindow::showStats(const QString &id) { for (const auto &j: jobs_) { if (j.id == id && j.has_merge_stats) { auto *win = new JFJochMergeStatsWindow(j.label, j.merge_stats, j.isa, j.merge_has_reference, - j.twinning, window()); + j.twinning, j.space_group_number, j.space_group_search, + window()); win->show(); return; } @@ -518,6 +519,8 @@ void JFJochProcessingJobsWindow::onFinished(ProcessResult result) { jobs_[row].isa = result.error_model_isa; jobs_[row].merge_has_reference = result.has_reference; jobs_[row].twinning = result.twinning; + jobs_[row].space_group_number = result.space_group_number; + jobs_[row].space_group_search = result.space_group_search; if (jobs_[row].graph_btn) jobs_[row].graph_btn->setEnabled(true); showStats(jobs_[row].id); diff --git a/viewer/windows/JFJochProcessingJobsWindow.h b/viewer/windows/JFJochProcessingJobsWindow.h index f24e21db..8554a981 100644 --- a/viewer/windows/JFJochProcessingJobsWindow.h +++ b/viewer/windows/JFJochProcessingJobsWindow.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include "../JFJochProcessController.h" @@ -64,6 +66,8 @@ private: double isa = 0.0; bool merge_has_reference = false; TwinningAnalysisResult twinning; // twinning test of the merged intensities + std::optional space_group_number; // final space group (searched or fixed) + std::optional space_group_search; // ranked candidates, when a search ran QToolButton *graph_btn = nullptr; // per-row "show statistics" button (enabled once stats exist) }; struct JobSpec { -- 2.54.0 From 936d2efd4af7064ab7d6cedcfd6fd5fc8942aa3e Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 09:57:21 +0200 Subject: [PATCH 07/47] viewer: bundle cuFFT in the Linux .tar.gz like the Windows build The Windows package ships cufft64_*.dll next to the viewer so a host with only an NVIDIA driver (no CUDA toolkit) can run it; the Linux .tar.gz shipped nothing equivalent and had no rpath, so a -linux-cuda archive needed cuFFT on the system. Mirror the Windows behaviour ONLY for the self-contained .tar.gz (JFJOCH_VIEWER_ONLY): install libcufft.so (with its SONAME/version symlink chain) into bin next to the binary and set INSTALL_RPATH=$ORIGIN so the loader picks the bundled copy. The .deb/.rpm builds deliberately do NOT bundle it - CUDA there is centrally managed by the distro's packages, and the .tar.gz is the one with no package manager, where we want the dependency set really minimal. cuFFT is the only dynamically-linked CUDA component (cudart and the fast-feedback indexer are static). macOS has no CUDA so it is excluded. Co-Authored-By: Claude Opus 4.8 --- viewer/CMakeLists.txt | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/viewer/CMakeLists.txt b/viewer/CMakeLists.txt index 40358381..c4efdd09 100644 --- a/viewer/CMakeLists.txt +++ b/viewer/CMakeLists.txt @@ -180,17 +180,32 @@ IF(Qt6_VERSION VERSION_GREATER_EQUAL "6.5") INSTALL(SCRIPT ${jfjoch_viewer_deploy_script} COMPONENT viewer) ENDIF() -# Bundle the cuFFT runtime DLL next to the viewer so the installed app runs on Windows hosts that -# have an NVIDIA driver but no CUDA toolkit. cuFFT is the ONLY CUDA component we link dynamically -# (cudart and the fast-feedback indexer are static), so it is the one runtime file the toolkit would -# otherwise have to provide; the DLL is self-contained (depends only on KERNEL32). CUDA 13 keeps -# redistributable DLLs in bin/x64, earlier toolkits in bin -- glob both. Windows + GPU build only. -IF(WIN32 AND JFJOCH_CUDA_AVAILABLE) - FILE(GLOB _cufft_dll - "${CUDAToolkit_BIN_DIR}/x64/cufft64_*.dll" - "${CUDAToolkit_BIN_DIR}/cufft64_*.dll") - IF(NOT _cufft_dll) - MESSAGE(FATAL_ERROR "cuFFT runtime DLL not found under ${CUDAToolkit_BIN_DIR}") +# Bundle the cuFFT runtime next to the viewer so the installed app runs on hosts that have an NVIDIA +# driver but no CUDA toolkit. cuFFT is the ONLY CUDA component we link dynamically (cudart and the +# fast-feedback indexer are static), so it is the one runtime file the toolkit would otherwise have to +# provide; it is self-contained (Windows: depends only on KERNEL32; Linux: only libc/libstdc++ and the +# always-present driver libcuda). GPU build only. macOS has no CUDA, so it is excluded. +IF(JFJOCH_CUDA_AVAILABLE) + IF(WIN32) + # CUDA 13 keeps redistributable DLLs in bin/x64, earlier toolkits in bin -- glob both. + FILE(GLOB _cufft_dll + "${CUDAToolkit_BIN_DIR}/x64/cufft64_*.dll" + "${CUDAToolkit_BIN_DIR}/cufft64_*.dll") + IF(NOT _cufft_dll) + MESSAGE(FATAL_ERROR "cuFFT runtime DLL not found under ${CUDAToolkit_BIN_DIR}") + ENDIF() + INSTALL(FILES ${_cufft_dll} DESTINATION bin COMPONENT viewer) + ELSEIF(UNIX AND NOT APPLE AND JFJOCH_VIEWER_ONLY) + # Only the self-contained Linux .tar.gz (JFJOCH_VIEWER_ONLY) bundles cuFFT: it has no package + # manager, so it must carry its runtime deps, and we want that set really minimal. The .deb/.rpm + # builds deliberately do NOT bundle it - CUDA there is centrally managed by the distro's packages. + # Ship libcufft.so beside the binary and add an $ORIGIN rpath so the loader finds the bundled copy + # (the same self-contained-app idea as the Windows DLL). CUDA::cufft is an UNKNOWN imported target, + # so install its file directly, resolving the symlink chain (libcufft.so -> .so. -> real + # file) so the libcufft.so. the binary is linked against lands in bin. + INSTALL(CODE + "file(INSTALL DESTINATION \"\${CMAKE_INSTALL_PREFIX}/bin\" TYPE SHARED_LIBRARY FOLLOW_SYMLINK_CHAIN FILES \"${CUDA_cufft_LIBRARY}\")" + COMPONENT viewer) + SET_TARGET_PROPERTIES(jfjoch_viewer PROPERTIES INSTALL_RPATH "$ORIGIN") ENDIF() - INSTALL(FILES ${_cufft_dll} DESTINATION bin COMPONENT viewer) ENDIF() -- 2.54.0 From 4374babe26dec7410205eb3a0c06f5ccf8fe2c44 Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 10:06:04 +0200 Subject: [PATCH 08/47] viewer: add a Browse button to the processing output prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The output prefix was a plain text field; add a 'Browse…' button that opens a file dialog to pick the output location. The chosen name is treated as a prefix (the _process.h5 / .mtz / .cif suffixes are appended), so a trailing extension is dropped, and the result is stored with native separators. Co-Authored-By: Claude Opus 4.8 --- viewer/windows/JFJochProcessingJobsWindow.cpp | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/viewer/windows/JFJochProcessingJobsWindow.cpp b/viewer/windows/JFJochProcessingJobsWindow.cpp index 73bb0a80..375809a2 100644 --- a/viewer/windows/JFJochProcessingJobsWindow.cpp +++ b/viewer/windows/JFJochProcessingJobsWindow.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -157,6 +158,24 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec QStringLiteral("%1/%2_run%3").arg(fi.absolutePath(), stem).arg(job_counter_ + 1)); auto *prefix = new QLineEdit(default_prefix, &dlg); + // "Browse…" opens a file dialog to choose where the outputs go. The chosen name is used as a prefix + // (suffixes like _process.h5 / .cif are appended), so drop a trailing extension. + auto *browse = new QPushButton("Browse…", &dlg); + connect(browse, &QPushButton::clicked, &dlg, [&dlg, prefix] { + const QString sel = QFileDialog::getSaveFileName(&dlg, "Output file prefix", prefix->text(), + "All Files (*)", nullptr, + QFileDialog::DontConfirmOverwrite); + if (sel.isEmpty()) + return; + const QFileInfo pf(sel); + prefix->setText(QDir::toNativeSeparators(pf.dir().filePath(pf.completeBaseName()))); + }); + auto *prefixRow = new QWidget(&dlg); + auto *prefixRowLayout = new QHBoxLayout(prefixRow); + prefixRowLayout->setContentsMargins(0, 0, 0, 0); + prefixRowLayout->addWidget(prefix); + prefixRowLayout->addWidget(browse); + // Rotation-vs-stills mode (rotation indexing + partiality + rot3d) is set in the settings panel via // "Process as stills"; the dialog only collects run options. Scaling applies to MX full analysis. auto *scaling = new QCheckBox("Scale && merge", &dlg); @@ -169,7 +188,7 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec form->addRow("Threads", threads); form->addRow(save_h5); form->addRow(save_merged); - form->addRow("Output prefix", prefix); + form->addRow("Output prefix", prefixRow); form->addRow(scaling); int result = 0; -- 2.54.0 From 85cf1b04d15d73b0b694c958013da8d51e53c35b Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 10:14:01 +0200 Subject: [PATCH 09/47] docs: changelog for rc.158 viewer/rugnux/packaging changes Co-Authored-By: Claude Opus 4.8 --- docs/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d776eba3..6aaed72a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog ## 1.0.0 +### 1.0.0-rc.158 +This is an UNSTABLE release. It includes many experimental features, as well as many AI generated fixes. We recommend using rc.152 for production use. + +* jfjoch_viewer: Open datasets on the WSL2/UNC filesystem (paths starting `\\`); write processing outputs next to the input file, with a Browse button and independent `_process.h5` / merged `.mtz`/`.cif` toggles; and show the determined space group in the merge-statistics window. +* rugnux: Accept an absolute `-o` output prefix in offline processing. +* Packaging: The self-contained Linux viewer `.tgz` now bundles cuFFT, so it runs without a system CUDA toolkit (`.deb`/`.rpm` are unchanged, distro-managed). + ### 1.0.0-rc.157 This is an UNSTABLE release. It includes many experimental features, as well as many AI generated fixes. We recommend using rc.152 for production use. -- 2.54.0 From 61eb661eca31eb0b50a4ab8f3fa3d4fa21377347 Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 10:14:25 +0200 Subject: [PATCH 10/47] ci: upload the Windows/CUDA viewer installer as a build artifact The Windows installer was only uploaded on tagged releases, so testing an arbitrary branch build meant cutting a tag. Also upload the CUDA installer as a workflow artifact on every run, with a short 3-day retention (it is a throwaway test build). Only the cuda variant, since that is the one we test. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/build_and_test.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.gitea/workflows/build_and_test.yml b/.gitea/workflows/build_and_test.yml index f85a93e6..264573ec 100644 --- a/.gitea/workflows/build_and_test.yml +++ b/.gitea/workflows/build_and_test.yml @@ -97,6 +97,16 @@ jobs: call "%VSPATH%\VC\Auxiliary\Build\vcvars64.bat" cd build cpack + - name: Upload CUDA installer as build artifact + # Keep the Windows/CUDA installer downloadable from every run (not just tagged releases) so it + # can be grabbed and tested. Short retention - it is a throwaway test build, not a release. + if: matrix.variant == 'cuda' + uses: actions/upload-artifact@v3 + with: + name: jfjoch-viewer-windows-cuda-installer + path: build/jfjoch-viewer-*-win64-cuda*.exe + retention-days: 3 + if-no-files-found: error - name: Upload installer to release if: github.ref_type == 'tag' shell: powershell -- 2.54.0 From 668855a9e78111fe882d40e0baf1a4a84e1af4e1 Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 10:20:34 +0200 Subject: [PATCH 11/47] viewer: show the merge resolution range in the stats hero row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The high-resolution limit is now usually set by the automatic CC1/2 logistic cutoff (when no scaling resolution is given), so it varies per run. Add a 'Resolution [Å]' hero card (low-res .. high-res edge from the overall shell) as the first card in the merge-statistics window. Co-Authored-By: Claude Opus 4.8 --- viewer/windows/JFJochMergeStatsWindow.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/viewer/windows/JFJochMergeStatsWindow.cpp b/viewer/windows/JFJochMergeStatsWindow.cpp index 02dd4018..05b3cb9b 100644 --- a/viewer/windows/JFJochMergeStatsWindow.cpp +++ b/viewer/windows/JFJochMergeStatsWindow.cpp @@ -86,6 +86,11 @@ JFJochMergeStatsWindow::JFJochMergeStatsWindow(const QString &title, const Merge auto num = [](double v, int d) { return std::isfinite(v) ? QString::number(v, 'f', d) : QStringLiteral("—"); }; auto *hero = new QHBoxLayout(); + // Resolution range of the merge. When no scaling resolution limit is given, the high-resolution + // edge is set by the CC1/2 logistic cutoff, so it varies per run - show it first. d_min is the + // high-res edge (small d), d_max the low-res edge. + if (o.d_min < o.d_max) + hero->addWidget(MakeCard(QString::asprintf("%.1f–%.2f", o.d_max, o.d_min), "Resolution [Å]", this)); hero->addWidget(MakeCard(num(isa, 1), "ISa", this)); hero->addWidget(MakeCard(pct(o.cc_half * 100.0), "CC½", this)); hero->addWidget(MakeCard(pct(o.r_meas * 100.0), "R-meas", this)); -- 2.54.0 From 54de990100d8314bd84856fa34027a9288bbbc69 Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 10:32:23 +0200 Subject: [PATCH 12/47] viewer: show only the high-resolution limit in the stats hero card Drop the low-resolution edge from the Resolution card - only the high-res limit (the CC1/2-cutoff value) is of interest. Co-Authored-By: Claude Opus 4.8 --- viewer/windows/JFJochMergeStatsWindow.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/viewer/windows/JFJochMergeStatsWindow.cpp b/viewer/windows/JFJochMergeStatsWindow.cpp index 05b3cb9b..3f0192ae 100644 --- a/viewer/windows/JFJochMergeStatsWindow.cpp +++ b/viewer/windows/JFJochMergeStatsWindow.cpp @@ -86,11 +86,10 @@ JFJochMergeStatsWindow::JFJochMergeStatsWindow(const QString &title, const Merge auto num = [](double v, int d) { return std::isfinite(v) ? QString::number(v, 'f', d) : QStringLiteral("—"); }; auto *hero = new QHBoxLayout(); - // Resolution range of the merge. When no scaling resolution limit is given, the high-resolution - // edge is set by the CC1/2 logistic cutoff, so it varies per run - show it first. d_min is the - // high-res edge (small d), d_max the low-res edge. + // High-resolution limit of the merge (d_min). When no scaling resolution limit is given it is set + // by the CC1/2 logistic cutoff, so it varies per run - show it first. if (o.d_min < o.d_max) - hero->addWidget(MakeCard(QString::asprintf("%.1f–%.2f", o.d_max, o.d_min), "Resolution [Å]", this)); + hero->addWidget(MakeCard(QString::asprintf("%.2f", o.d_min), "Resolution [Å]", this)); hero->addWidget(MakeCard(num(isa, 1), "ISa", this)); hero->addWidget(MakeCard(pct(o.cc_half * 100.0), "CC½", this)); hero->addWidget(MakeCard(pct(o.r_meas * 100.0), "R-meas", this)); -- 2.54.0 From 7da60c338572f182fc4d1cbd79f3d975d91efba4 Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 10:33:32 +0200 Subject: [PATCH 13/47] ci: use gitea-upload-artifact@v4 for the Windows/CUDA artifact Switch to the internal Gitea artifact action (actions/gitea-upload-artifact@v4, hosted at gitea.psi.ch) instead of actions/upload-artifact@v3. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/build_and_test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build_and_test.yml b/.gitea/workflows/build_and_test.yml index 264573ec..9dad7e93 100644 --- a/.gitea/workflows/build_and_test.yml +++ b/.gitea/workflows/build_and_test.yml @@ -101,7 +101,7 @@ jobs: # Keep the Windows/CUDA installer downloadable from every run (not just tagged releases) so it # can be grabbed and tested. Short retention - it is a throwaway test build, not a release. if: matrix.variant == 'cuda' - uses: actions/upload-artifact@v3 + uses: actions/gitea-upload-artifact@v4 with: name: jfjoch-viewer-windows-cuda-installer path: build/jfjoch-viewer-*-win64-cuda*.exe -- 2.54.0 From c3468eef6c5359b16f7f8ec1df5dc88097e47c86 Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 10:35:00 +0200 Subject: [PATCH 14/47] ci: use actions/upload-artifact@v4 for the Windows/CUDA artifact The internal Gitea action is referenced as actions/upload-artifact@v4 (no gitea- prefix), per its own documentation. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/build_and_test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build_and_test.yml b/.gitea/workflows/build_and_test.yml index 9dad7e93..30212666 100644 --- a/.gitea/workflows/build_and_test.yml +++ b/.gitea/workflows/build_and_test.yml @@ -101,7 +101,7 @@ jobs: # Keep the Windows/CUDA installer downloadable from every run (not just tagged releases) so it # can be grabbed and tested. Short retention - it is a throwaway test build, not a release. if: matrix.variant == 'cuda' - uses: actions/gitea-upload-artifact@v4 + uses: actions/upload-artifact@v4 with: name: jfjoch-viewer-windows-cuda-installer path: build/jfjoch-viewer-*-win64-cuda*.exe -- 2.54.0 From e7fbeb527f4f19e7cf1364c48ab7760f7db01e45 Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 11 Jul 2026 10:59:15 +0200 Subject: [PATCH 15/47] docs: update analysis references to match current code Reconcile docs/CPU_DATA_ANALYSIS.md and docs/RUGNUX.md with the current image_analysis/ and rugnux CLI (verified section-by-section against the code): unified profile-fit Bragg integration engine, multi-lattice indexing, azimuthal phi binning, radial parallax/bandwidth profile with sub-pixel centring, rot3d capture-fraction handling, automatic CC1/2 resolution cutoff, and the new rugnux options; fix the section numbering and cross-references; remove the never-implemented French-Wilson and still-partiality descriptions. Delete the stale in-source design notes (ICE_RING_DETECTION, BRAGG_INTEGRATION_ENGINE, NEXTGEN_INTEGRATOR) and fix the two code comments that pointed at them; the BraggIntegrationEngine header no longer claims it is 'not yet wired'. Co-Authored-By: Claude Opus 4.8 --- common/BraggIntegrationSettings.h | 2 +- docs/CHANGELOG.md | 1 + docs/CPU_DATA_ANALYSIS.md | 147 +++---- docs/RUGNUX.md | 30 +- image_analysis/azint/ICE_RING_DETECTION.md | 71 ---- .../BRAGG_INTEGRATION_ENGINE.md | 107 ----- .../BraggIntegrationEngine.h | 7 +- .../bragg_integration/NEXTGEN_INTEGRATOR.md | 391 ------------------ 8 files changed, 97 insertions(+), 659 deletions(-) delete mode 100644 image_analysis/azint/ICE_RING_DETECTION.md delete mode 100644 image_analysis/bragg_integration/BRAGG_INTEGRATION_ENGINE.md delete mode 100644 image_analysis/bragg_integration/NEXTGEN_INTEGRATOR.md diff --git a/common/BraggIntegrationSettings.h b/common/BraggIntegrationSettings.h index 3c3b724c..bd53b7db 100644 --- a/common/BraggIntegrationSettings.h +++ b/common/BraggIntegrationSettings.h @@ -9,7 +9,7 @@ // profile-fits with a measured-width Gaussian (Kabsch-style) - more accurate intensities than the // classical uniform BoxSum; validated on HEWL anomalous (stronger S/Cl peaks vs box-sum). BoxSum is // the simpler, faster fallback. ProfileEmpirical learns the profile per resolution shell from strong -// spots - see bragg_integration/BRAGG_INTEGRATION_ENGINE.md. +// spots - see docs/CPU_DATA_ANALYSIS.md (Bragg integration). enum class IntegratorMode { BoxSum, ProfileGaussian, ProfileEmpirical }; class BraggIntegrationSettings { diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6aaed72a..177887fe 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,7 @@ This is an UNSTABLE release. It includes many experimental features, as well as * jfjoch_viewer: Open datasets on the WSL2/UNC filesystem (paths starting `\\`); write processing outputs next to the input file, with a Browse button and independent `_process.h5` / merged `.mtz`/`.cif` toggles; and show the determined space group in the merge-statistics window. * rugnux: Accept an absolute `-o` output prefix in offline processing. * Packaging: The self-contained Linux viewer `.tgz` now bundles cuFFT, so it runs without a system CUDA toolkit (`.deb`/`.rpm` are unchanged, distro-managed). +* Docs: Bring the analysis references up to date with the code. `docs/CPU_DATA_ANALYSIS.md` now reflects the unified profile-fit Bragg integration engine, multi-lattice indexing, azimuthal phi binning, the radial parallax/bandwidth profile with sub-pixel centring, the rot3d capture-fraction handling and the automatic CC1/2 resolution cutoff, and drops the descriptions of features that were never implemented (French-Wilson amplitudes, the still excitation-error partiality model); `docs/RUGNUX.md` documents the new `--resolution-cutoff`/`--resolution-cc-target`/`--resolution-shells`, `--min-captured-fraction`, `--mosaicity`, `--reference-column`, the azimuthal correction toggles and the geometry-override options, and corrects the `-N` default. The outdated in-source design notes (ICE_RING_DETECTION, BRAGG_INTEGRATION_ENGINE, NEXTGEN_INTEGRATOR) are removed. ### 1.0.0-rc.157 This is an UNSTABLE release. It includes many experimental features, as well as many AI generated fixes. We recommend using rc.152 for production use. diff --git a/docs/CPU_DATA_ANALYSIS.md b/docs/CPU_DATA_ANALYSIS.md index 27ba5295..a3343dde 100644 --- a/docs/CPU_DATA_ANALYSIS.md +++ b/docs/CPU_DATA_ANALYSIS.md @@ -11,7 +11,7 @@ This document describes the crystallographic algorithms implemented in Jungfrauj 5. Bravais lattice / centering inference, 6. geometry and lattice refinement, 7. reflection prediction (still and rotation), -8. Bragg integration by either 2D summation or reference-driven profile fitting, +8. Bragg integration by either 2D box summation or profile fitting (Kabsch, reference-free), 9. scaling and merging, 10. merge-level error modelling and outlier rejection, 11. auxiliary statistics (Wilson plot, ⟨I/σ(I)⟩, CC1/2, CCref). @@ -87,29 +87,30 @@ $ $ Jungfraujoch uses $|\Delta_\mathrm{Ewald}|$ as an operational proxy for excitation error. This appears in: - still prediction (accept if $|\Delta_\mathrm{Ewald}|\le \Delta_\mathrm{cut}$), -- profile radius estimation (see §7.1), -- still partiality option in scaling/merging (§9.3). +- profile radius estimation (see §11.1), +- still partiality option in scaling/merging (§10.2). --- ## 2. Azimuthal integration (radial profiles) -Azimuthal integration produces a 1D radial profile $I(q)$ or $I(d)$ by histogramming pixels into radial bins. Pixels are **not split** across bins; each pixel contributes wholly to a single bin. +Azimuthal integration produces a radial profile $I(q)$ or $I(d)$ by histogramming pixels into radial bins. Pixels are **not split** across bins; each pixel contributes wholly to a single bin. By default the profile is purely radial (a single azimuthal bin), but the azimuth can optionally be split into up to 512 $\phi$ sectors (`azim_bins`, `--azim-phi-bins`), giving a **2D $q\times\phi$ profile** that exposes azimuthal anisotropy such as detector shadowing or sample texture. ### 2.1 Histogram estimator -Let bin index $b(x,y)\in\{0,\dots,B-1\}$ be precomputed from $q(x,y)$ (or equivalently from $d(x,y)$). For each bin $b$: +Let bin index $b(x,y)$ be precomputed from $q(x,y)$ (or equivalently from $d(x,y)$) and, when $\phi$ sectors are enabled, the azimuth $\phi(x,y)$ — so $b = b_q + b_\phi B_q$. For each bin $b$: -- accumulate corrected intensity: +- accumulate corrected intensity and its square: $ - S_b = \sum_{(x,y):\,b(x,y)=b} I(x,y)\,C(x,y), + S_b = \sum_{(x,y):\,b(x,y)=b} I(x,y)\,C(x,y),\qquad + S^{(2)}_b = \sum I(x,y)^2\,C(x,y)^2, $ - and count: $ N_b = \#\{(x,y):\,b(x,y)=b \text{ and pixel is valid}\}. $ -A simple mean profile is then $ \bar{I}_b = S_b / N_b$ (when $N_b>0$). Invalid pixels (masked, saturated, detector error codes) are excluded. +The profile reports both the mean $\bar{I}_b = S_b / N_b$ (when $N_b>0$) and a per-bin sample standard deviation $\sigma_b = \sqrt{(S^{(2)}_b - S_b^2/N_b)/(N_b-1)}$ (a spread/error estimate for each radial point). Invalid pixels (masked, saturated, detector error codes) are excluded. ### 2.2 Corrections applied @@ -117,8 +118,9 @@ Two standard corrections are available: **(i) Solid angle / geometric correction.** A commonly used approximation for flat detectors gives a $\cos^3(2\theta)$ factor: $ -C_\Omega(2\theta) = \cos^3(2\theta). +C_\Omega(2\theta) = \cos^3(2\theta), $ +applied — like the polarization term below — as a **divisor** (intensities are scaled by $1/\cos^3(2\theta)$), so pixels at higher $2\theta$, which subtend a smaller solid angle, are boosted. **(ii) Polarization correction.** With polarization coefficient $P$ (beamline dependent) and azimuth $\phi$: $ @@ -129,7 +131,7 @@ applied as a divisor to intensities (i.e. scale by $1/C_\mathrm{pol}$) when enab ### 2.3 Background estimate for profiles -A background estimate is derived from the integrated profile using the azimuthal integration settings (details depend on the configured estimator). This background is used for monitoring and diagnostics; it is **not** the same as local Bragg-spot background used in summation integration (§8). +A background estimate is derived from the profile as its mean intensity over a fixed low-to-mid $Q$ window (default $2\pi/5$ to $2\pi/3$ Å$^{-1}$). This background is used for monitoring and diagnostics; it is **not** the same as the local Bragg-spot background used in summation integration (§9.2). --- @@ -163,6 +165,7 @@ $ A pixel is considered strong if: - it is above a photon/count threshold, and +- its window contains enough valid neighbours (more than 100), so the local statistics are meaningful, and - $\Delta>0$, and - the squared deviation exceeds a scaled variance: $ @@ -178,7 +181,7 @@ Special cases: ### 3.2 Resolution and ice-ring handling -Spot finding can be restricted to a resolution range $[d_\mathrm{high}, d_\mathrm{low}]$ by masking pixels outside the range. Optionally, pixels in identified ice-ring regions can be tagged so that subsequent indexing/refinement may include or exclude them (see §4 and §6). +Spot finding can be restricted to a resolution range $[d_\mathrm{high}, d_\mathrm{low}]$ by masking pixels outside the range. Optionally, spots in identified ice-ring regions can be tagged so that subsequent indexing/refinement may include or exclude them (see §4 and §6). A single per-image **ice-ring score** is derived from the azimuthally-integrated radial profile: for each hexagonal-ice powder ring (positions $d$ from Moreau *et al.*, Acta Cryst D77, 2021), the profile intensity at the ring is divided by a smooth background estimated from the *whole* profile — a running median of the non-ice bins, interpolated under each ring — and the strongest ring's ratio is reported (1 = no ice, $>1$ = ice above background). A whole-profile background is used rather than a couple of adjacent shoulder bins so the estimate is robust to the radial binning: at a coarse Q-spacing a local shoulder can be only ~1 bin and would double-count the ring's own edge (offline processing defaults to a fine 0.01 1/Å spacing, `--azim-q-spacing`, so the rings are well resolved). (A significance/z-score was considered but is uninformative here: with many photons any real ice ring is highly significant, so the discriminating quantity is the ice *magnitude*, i.e. this ratio.) It is stored per image (`ice_ring_score`, HDF5 `/entry/MX/iceRingScore`) as a monitoring quantity, distinct from the merge-time ice masking, which is data-driven from the per-ring merged CC1/2. @@ -211,7 +214,7 @@ Jungfraujoch supports two complementary indexing strategies: 1. **FFT-based indexing** (Rossmann-type): does not require an a priori unit cell; suitable for unknown samples. 2. **Fast-feedback indexing** (TORO-like): requires an approximate unit cell; optimized for speed and feedback. -Both feed into a common robust refinement/selection stage which maximizes the number of inliers under an indexing tolerance. +Both feed into a common robust refinement/selection stage which maximizes the number of inliers under an indexing tolerance, and which can return **more than one lattice** per image (multi-lattice indexing; see §5.4). ### 4.1 Indexed-spot decision (inlier test) @@ -225,7 +228,7 @@ Let $(h,k,l)=(\mathrm{round}(h_f),\mathrm{round}(k_f),\mathrm{round}(l_f))$ and $ \delta^2 = (h_f-h)^2 + (k_f-k)^2 + (l_f-l)^2. $ -A spot is indexed if $\delta^2 \le \tau^2$, where $\tau$ is the configured tolerance. +A spot is indexed if $\delta^2 < \tau^2$, where $\tau$ is the configured tolerance. For indexed spots, the reciprocal lattice point $\mathbf{p} = h\mathbf{a}^*+k\mathbf{b}^*+l\mathbf{c}^*$ is used to compute $\Delta_\mathrm{Ewald}(\mathbf{p})$ (stored as a diagnostic and later used in profile-radius estimation). @@ -250,7 +253,7 @@ where $L_\mathrm{max}$ is the maximum expected real-space unit-cell edge (Å). T ### 5.2 FFT peak picking and candidate vectors -For each direction, the FFT magnitude spectrum is computed; peaks correspond to periodicities along $\mathbf{u}_d$. Each direction yields a candidate real-space length $L$ with maximum spectral magnitude (subject to $L\ge L_\mathrm{min}$). +For each direction, the FFT magnitude spectrum is computed; peaks correspond to periodicities along $\mathbf{u}_d$. Each direction yields a candidate real-space length $L$ chosen **not** by raw magnitude but by **maximum prominence above a running-mean local background** (subtracting the broad low-frequency envelope that otherwise dominates on weak or pink-beam frames), subject to $L\ge L_\mathrm{min}$. Candidate vectors are $\mathbf{v}_d = L_d\,\mathbf{u}_d$. @@ -258,22 +261,16 @@ A collinearity filter removes nearly parallel vectors (e.g. within 5°) and atte ### 5.3 Lattice reduction and cell candidates -Triples of candidate vectors are combined to form candidate bases $(\mathbf{A},\mathbf{B},\mathbf{C})$. A simple reduction is applied: -$ -\mathbf{B} \leftarrow \mathbf{B} - \mathrm{round}\!\left(\frac{\mathbf{B}\cdot\mathbf{A}}{\mathbf{A}\cdot\mathbf{A}}\right)\mathbf{A}, -$ -$ -\mathbf{C} \leftarrow \mathbf{C} - \mathrm{round}\!\left(\frac{\mathbf{C}\cdot\mathbf{A}}{\mathbf{A}\cdot\mathbf{A}}\right)\mathbf{A} -- \mathrm{round}\!\left(\frac{\mathbf{C}\cdot\mathbf{B}}{\mathbf{B}\cdot\mathbf{B}}\right)\mathbf{B}. -$ - -Candidates are filtered by allowed length and angle ranges. +Triples of candidate vectors are combined to form candidate bases $(\mathbf{A},\mathbf{B},\mathbf{C})$, each reduced to its **Niggli-reduced cell** (Gruber-vector reduction) before comparison, and filtered by allowed length and angle ranges. Two passes are run: a standard pass forms shortest-vector triples from the ~30 strongest filtered directions; if the best cell then indexes fewer than half the spots, a **widened fallback** anchors the two shortest axes and lets the third range over up to ~60 candidate vectors (deduplicated by Niggli cell), catching large, elongated or superstructure cells the first pass misses. ### 5.4 Robust refinement and best-cell selection -Candidate bases are refined against observed spots using an iterative inlier‑focused least‑squares procedure (trimmed/contracting threshold). The output cell is chosen to: -1. maximize the number of indexed spots under the tolerance $\tau$, and -2. break ties by a refined score (smaller residual threshold/score is preferred). +Candidate bases are refined against observed spots using an iterative inlier‑focused least‑squares procedure (trimmed/contracting threshold). Candidates are then ranked: +1. more indexed spots wins — **unless** two candidates index within ~10 % of each other, in which case +2. the **smaller-volume** cell is preferred (when the volumes differ by more than ~5 %), avoiding a doubled supercell, then +3. the smaller refinement score, then the spot count again. + +Selection is **not limited to a single lattice**: after the best cell is accepted, further lattices are added as separate crystals provided fewer than ~40 % of their indexed spots overlap an already-accepted lattice (up to two extra by default), so split or multi-lattice crystals are indexed rather than discarded. An optional reference unit cell (if supplied) restricts acceptance to cells within a relative distance tolerance in edge lengths (permutation-invariant). @@ -292,9 +289,9 @@ If not, Jungfraujoch attempts to infer the most plausible Bravais lattice type f The output includes: - a conventional cell, - crystal system (triclinic, monoclinic, …), -- centering symbol $P, A, B, C, I, F, R$. +- centering symbol (one of $P, C, I, F, R$; the $A/B$ variants are not emitted here — they are handled only later as prediction absences, §8.4). -This stage provides centering information used for systematic absences in prediction (§7.3) and for reporting. +This stage provides centering information used for systematic absences in prediction (§8.4) and for reporting. **Note.** In ambiguous or special cases, forcing space group to $P1$ (no symmetry assumptions) is recommended. @@ -315,6 +312,8 @@ The refinement jointly optimizes, depending on mode and constraints: - crystal orientation (a global rotation), - unit-cell parameters, with constraints determined by inferred crystal system. +By default only the beam center, unit cell and crystal orientation are refined; the detector distance, tilt angles and rotation-axis direction are held fixed unless explicitly enabled. A lighter **orientation-only** mode refines just the crystal orientation (with a weak small-rotation prior on the poorly-determined out-of-plane component), for stills whose geometry is already trusted. + For higher symmetries, constraints are enforced, e.g. - cubic: $a=b=c,\ \alpha=\beta=\gamma=90^\circ$, - tetragonal: $a=b$, @@ -341,15 +340,15 @@ For oscillation/rotation data, each image corresponds to a rotation angle $\phi$ $ \mathbf{s}_\mathrm{obs,ref} = R(\phi)\,\mathbf{s}_\mathrm{obs}, $ -with $R(\phi)$ constructed from the axis-angle representation of the goniometer model. +with $R(\phi)$ constructed from the axis-angle representation of the goniometer model. The angle $\phi$ is taken at the centre of each frame's oscillation (the frame angle plus half the oscillation width). ### 7.4 Multi-stage tightening of inlier tolerance -Refinement is performed in stages with decreasing acceptance tolerance for including reflections (e.g. from coarse to fine), which stabilizes convergence when starting from imperfect indexing and approximate geometry. +Refinement is performed in stages with decreasing acceptance tolerance for including reflections (three stages, indexing tolerance $0.3\to0.2\to0.1$), which stabilizes convergence when starting from imperfect indexing and approximate geometry. --- -## 9. Bragg integration +## 8. Reflection prediction Jungfraujoch predicts reflection positions for integration by enumerating Miller indices within a resolution cutoff and accepting those that satisfy a diffraction condition model. @@ -368,6 +367,8 @@ $ $ Accepted reflections are projected to the detector by intersecting the diffracted direction $\mathbf{S}=\mathbf{S}_0+\mathbf{p}$ with the detector plane, using the current geometry. +When the beam has a finite energy bandwidth, this window is **broadened radially per reflection**: the cutoff is combined in quadrature with a bandwidth smear, $\sqrt{\Delta_\mathrm{cut}^2 + (3\,\sigma_\mathrm{bw})^2}$, where $\sigma_\mathrm{bw}\propto|p_z|$ (the reciprocal-space depth along the beam, growing as $\sim 1/d^2$). This keeps high-resolution reflections — smeared by the bandwidth into radial streaks — from being clipped. The same $\sigma_\mathrm{bw}$ is deconvolved from the measured profile radius (§11.1), so it is not double-counted. + ### 8.3 Rotation prediction (Laue equation + partiality model) For rotation/oscillation datasets, Jungfraujoch solves for rotation angles $\phi$ where the rotated reciprocal lattice point satisfies the Ewald-sphere condition. In an XDS-like notation, define: @@ -422,7 +423,7 @@ Pixels are classified by their squared distance $r^2=(x-x_p)^2+(y-y_p)^2$: - **signal region:** $r^2 < r_1^2$, - **background annulus:** $r_2^2 \le r^2 < r_3^2$. -Invalid pixels (masked/bad/saturated) are excluded from both sums. +Invalid pixels (masked/bad/saturated) are excluded from both sums. In addition, pixels lying inside the signal disk ($r_iterN_scale.dat`. Merged statistics (⟨I/σ⟩, CC1/2, completeness, …), the error model and timing are printed to the -console. +console. By default the written resolution is trimmed automatically where CC1/2 falls off +(`--resolution-cutoff cc-logistic`, CC1/2 target 0.30); set `--scaling-high-resolution` to fix the +limit by hand, or `--resolution-cutoff off` to keep the full range. ## Re-scaling and re-merging (`rugnux --scale`) @@ -114,7 +116,7 @@ General: | Option | Description | | --- | --- | | `-o, --output-prefix ` | Output file prefix (default: `output`) | -| `-N, --threads ` | Number of worker threads (default: 1) | +| `-N, --threads ` | Number of worker threads (default: all hardware threads) | | `-s, --start-image ` | First image to process (default: 0) | | `-e, --end-image ` | Last image to process (default: all) | | `-t, --stride ` | Process every *n*-th image (default: 1) | @@ -135,7 +137,7 @@ Spot finding: | `--spot-threshold ` | Photon-count threshold for spot finding (default: 10) | | `--spot-high-resolution ` | High-resolution limit for spot finding, Å (default: 1.5) | | `--max-spots ` | Maximum spot count (default: 250) | -| `--detect-ice-rings[=on\|off]` | Flag ice-ring spots and exclude ice-ring reflections from scaling/merging; overrides the dataset setting (default: use the dataset value) | +| `--detect-ice-rings[=on\|off]` | Flag ice-ring spots (de-prioritised in indexing) and exclude ice-ring reflections from scaling/merging; overrides the dataset/master-file setting (default: use the dataset value) | Azimuthal integration (the radial profile behind the per-image ice-ring score): @@ -145,6 +147,8 @@ Azimuthal integration (the radial profile behind the per-image ice-ring score): | `--azim-min-q ` | Minimum Q, 1/Å | | `--azim-max-q ` | Maximum Q, 1/Å | | `--azim-phi-bins ` | Number of azimuthal (phi) bins (default: 1) | +| `--polarization-correction ` | Enable/disable the azimuthal polarization correction | +| `--solid-angle-correction ` | Enable/disable the azimuthal solid-angle correction | Indexing: @@ -179,14 +183,20 @@ Scaling and merging: | `--scale-fulls` / `--no-scale-fulls` | rot3d: refit a per-frame scale on the combined fulls (XDS order, Unity model); on by default for rotation data, off for stills | | `--smooth-g[=deg]` | rot3d: smooth the per-frame scale *G* over a degree range before the 3D combine (XDS DELPHI-like; default 5° for rotation, 0 = off) | | `--capture-uncertainty ` | rot3d: systematic sigma on under-captured fulls, ~num·(1−captured_fraction)·I (default: 1.0 for rotation, 0 otherwise) | -| `--scaling-high-resolution ` | High-resolution limit for scaling, Å (default: no limit) | +| `--min-captured-fraction ` | rot3d: drop a combined full whose rocking curve was captured below this fraction — edge-of-sweep truncated fulls (default: 0.7 for rotation, 0 otherwise; 0 = off) | +| `--scaling-high-resolution ` | High-resolution limit for scaling, Å — manual override (default: no limit; disables the automatic cutoff below) | +| `--resolution-cutoff ` | Automatic high-resolution cutoff for the written reflections and reported shells: `cc-logistic` \| `off` (default: `cc-logistic`; ignored when `--scaling-high-resolution` is set) | +| `--resolution-cc-target ` | CC1/2 target defining the `cc-logistic` fall-off (default: 0.30) | +| `--resolution-shells ` | Number of resolution shells in the reported statistics table (default: 10) | | `--min-partiality ` | Minimum partiality to accept a reflection (default: 0.02) | | `--reject-outliers ` | Per-observation outlier rejection, N σ from the per-reflection median (default: 6 for `rot3d`, off otherwise) | | `--reject-delta-cchalf ` | Drop images with ΔCC1/2 below mean − N·stddev (default: off) | | `--min-image-cc ` | Per-image CC limit, percent (default: no limit) | +| `--mosaicity ` | Diagnostic: fix the scaling mosaicity (°) instead of using the per-image seed | | `--scaling-iterations ` | Scaling iterations with no reference data (default: 3) | | `--scaling-output ` | Reflection output format: `cif` (mmCIF, default) \| `mtz` \| `txt` | | `-z, --reference-mtz ` | Reference MTZ (enables reference-driven scaling) | +| `--reference-column