From 0b567061ce88b4e5e6ddf5935c7e1681a0d903ef Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 8 Sep 2026 12:20:03 +0200 Subject: [PATCH] grid scan: the run finds the crystals and the viewer can ask it to A grid scan is analysed once, from the completed map, and the answer belongs to the run rather than to whoever happened to call it. rugnux computed it after RunPipeline had already written the end message, so the crystals reached the raster report and nothing else - the _process.h5 carried the per-cell scores and no crystal list, and a viewer re-opening that file had nothing to draw. Rugnux now accumulates the raster with ScanResultGenerator, the same accumulator the online receiver fills, runs AnalyzeGridScan at the end message for the same stated reason the receiver does, sets EndMessage::grid_crystals so the file gets /entry/MX/crystals, and returns the result on ProcessResult. The CLI consumes that instead of analysing the map a second time; the raster report and JSON are unchanged, verified against the previous binary on a real raster (identical crystal, identical report; the ice score differs in the seventh decimal, which the same binary does run to run). The accumulator keyed a cell on the message's number, which for rugnux is the ordinal of the images -s/-e/--stride selected, not the image's place in the raster. It now reads original_number where there is one, as the writer already does; both receivers set it equal to number, so nothing online changes. jfjoch_viewer offers Grid as a fourth mode beside MX, AzInt and Calib, configures the job with spot finding on and indexing from the experiment's grid-scan settings, and refuses the mode on a dataset that has no grid scan rather than scoring every image and reporting nothing. The shared azimuthal section is shown for it, which it needs - the ice score's radial channel reads that profile. Verified end to end: Grid, then Analyze dataset, on a stored raster leaves the composite map on screen with a frame at each crystal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N --- common/ScanResultGenerator.cpp | 16 ++++-- docs/CHANGELOG.md | 1 + rugnux/Rugnux.cpp | 27 +++++++++ rugnux/Rugnux.h | 4 ++ rugnux/rugnux_cli.cpp | 18 +++--- tests/AnalyzeGridScanTest.cpp | 31 +++++++++++ tests/RugnuxTest.cpp | 55 ++++++++++++++++++- viewer/widgets/JFJochViewerSettingsDock.cpp | 26 ++++++--- viewer/windows/JFJochProcessingJobsWindow.cpp | 26 ++++++++- 9 files changed, 178 insertions(+), 26 deletions(-) diff --git a/common/ScanResultGenerator.cpp b/common/ScanResultGenerator.cpp index c510e2f1a..b101040cf 100644 --- a/common/ScanResultGenerator.cpp +++ b/common/ScanResultGenerator.cpp @@ -26,20 +26,26 @@ ScanResultGenerator::ScanResultGenerator(const DiffractionExperiment &experiment void ScanResultGenerator::Add(const DataMessage &message) { std::unique_lock ul(m); - int64_t image_number = message.number; + // The image's OWN number in the file, which is what a grid position and a goniometer angle are + // both a function of. A receiver numbers images as it collects them, so the two are the same + // there; rugnux numbers the images it SELECTED (-s/-e/--stride) and only original_number says + // where in the run a frame actually sat. Same idiom as the writer (writer/HDF5DataFile.cpp). + const int64_t number = message.original_number.value_or(message.number); + + int64_t image_number = number; if (grid_scan) image_number = grid_scan->Rearrange(image_number); if (image_number >= 0 && static_cast(image_number) < v.size()) { if (grid_scan) { - v[image_number].x = grid_scan->GetElementPosX_step(message.number); - v[image_number].y = grid_scan->GetElementPosY_step(message.number); + v[image_number].x = grid_scan->GetElementPosX_step(number); + v[image_number].y = grid_scan->GetElementPosY_step(number); } else if (goniometer_axis) { - v[image_number].angle_deg = goniometer_axis->GetAngle_deg(message.number); + v[image_number].angle_deg = goniometer_axis->GetAngle_deg(number); } - v[image_number].number = message.number; + v[image_number].number = number; v[image_number].pixel_sum = message.pixel_sum; v[image_number].collection_efficiency = message.image_collection_efficiency.value_or(1.0); v[image_number].bkg = message.bkg_estimate; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1161df823..3e4518490 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,7 @@ * Rotation MX analysis is not available in jfjoch_broker and is processed with rugnux; every other method carries its own settings on its own endpoint, `/config/grid_scan_analysis` and `/config/calibration_settings`, and `PowderCalibration` computes the azimuthal profile on the CPU with a sector count of its own. * A grid scan finds the crystals in the finished map and reports them - grid position, nearest image, measured size and orientation, score, ice score and resolution - through the CBOR end message (`grid_crystals`), the HDF5 master (`/entry/MX/crystals`) and `GET /result/scan`; `/config/grid_scan_analysis` governs what counts as one - the protein-score threshold, the grow threshold, the minimum cells per crystal, the decisive single-cell score, the crystal cap, and whether each cell is indexed, which it is by default. * jfjoch_viewer draws a grid scan as one map - protein in orange, ice in cyan, mixed where both are present, over the shape of the loop - with a frame around each crystal found, at its own orientation. +* jfjoch_viewer runs the grid-scan analysis itself: the settings panel offers Grid beside MX, AzInt and Calib, and "Analyze dataset" scores the whole raster and draws the crystals it found on the map. * `rugnux --mode raster` runs the same analysis over a stored grid scan and writes `_raster_report.txt` and `_raster.json`; `--beam-size` states the beam the reported sizes still contain, and where neither it nor the file says, the grid step is reported in its place rather than zero. * The per-image ice ring measure is renamed `ice_ring_ratio` (`/entry/MX/iceRingRatio`, plot type `ice_ring_ratio`); the retired `ice_ring_score` spelling is still read from older files and streams, and still accepted by the API. * `rugnux --model` reports CC(model, data) by resolution shell on the same shells as CC1/2, with the reflection count and a significance for each, and fits the model's scale, anisotropic B and bulk solvent on the working reflections only, so the R-free is measured against a model no free reflection helped scale. diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index e21720e41..058688f3c 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -32,6 +32,7 @@ #include "../common/CUDAWrapper.h" #include "../common/JFJochException.h" #include "../common/time_utc.h" +#include "../common/ScanResultGenerator.h" #include "../writer/FileWriter.h" #include "../image_analysis/MXAnalysisWithoutFPGA.h" #include "../image_analysis/beam_stop/ShadowFinder.h" @@ -42,6 +43,7 @@ #include "../image_analysis/geom_refinement/GeometryRefiner.h" #include "../image_analysis/indexing/IndexerThreadPool.h" #include "../image_analysis/spot_finding/ImageSpotFinderCPU.h" +#include "../image_analysis/grid_scan_analysis/AnalyzeGridScan.h" #include "gemmi/twin.hpp" #include "../image_analysis/azint/AzIntEngineCPU.h" #include "../image_analysis/image_preprocessing/ImagePreprocessorCPU.h" @@ -3404,6 +3406,13 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b std::mutex calibration_spot_m; std::vector calibration_spot_list; + // Grid scan: the per-cell scores the crystal search reads, scattered onto the raster as they + // arrive. The same accumulator the online receiver fills, so both front ends hand AnalyzeGridScan + // the same object. The search itself runs once, at the end message - see there for why. + std::optional grid_scan_result; + if (config_.mode == AnalysisMode::Grid && experiment_.GetGridScan().has_value()) + grid_scan_result.emplace(experiment_); + auto azint_worker = [&]() { std::vector decompression_buffer; ImagePreprocessorCPU preprocessor(experiment_, pixel_mask_); @@ -3462,6 +3471,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b msg.run_name = experiment_.GetRunName(); plots.Add(msg, profile); + if (grid_scan_result) grid_scan_result->Add(msg); if (writer_queue) writer_queue->Post(msg, img); note_written(ordinal); if (observer) observer->OnImageProcessed(msg); @@ -3518,6 +3528,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b } plots.Add(msg, profile); + if (grid_scan_result) grid_scan_result->Add(msg); if (writer_queue) writer_queue->Post(msg, img); note_written(ordinal); if (observer) observer->OnImageProcessed(msg); @@ -3671,6 +3682,22 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b end_msg.az_int_result["dataset"] = plots.GetAzIntProfile(); end_msg.indexing_rate = result.indexing_rate; + // A grid scan's crystals are found once, from the completed map. The decision is about the whole + // raster and cannot be taken while it runs: a patch that looks like the best crystal in row three + // is often the shoulder of a better one two rows further down, and a scan exists precisely to see + // the whole loop before choosing. Done here so one list reaches both the written file (through + // this end message, as /entry/MX/crystals) and the caller (through ProcessResult). + if (grid_scan_result) { + result.grid_scan = AnalyzeGridScan(grid_scan_result->GetResult(), + experiment_.GetGridScan().value(), + experiment_.GetBeamSizeX_um().value_or(0.0f), + experiment_.GetBeamSizeY_um().value_or(0.0f), + experiment_.GetGridScanAnalysisSettings()); + end_msg.grid_crystals = result.grid_scan->crystals; + logger.Info("Grid scan: {} crystal(s) found in {} grid points", + result.grid_scan->crystals.size(), result.images_processed); + } + if (full && !cancelled_) { if (const auto rot = indexer->FinalizeRotationIndexing(); rot.has_value()) { end_msg.rotation_lattice = rot->lattice; diff --git a/rugnux/Rugnux.h b/rugnux/Rugnux.h index cc67524ea..3027dc5e2 100644 --- a/rugnux/Rugnux.h +++ b/rugnux/Rugnux.h @@ -298,6 +298,10 @@ struct ProcessResult { // Calibration mode: the geometry fitted to the powder rings of the whole run. std::optional calibration; + // Grid mode: the crystals found in the completed raster. The same list the run writes to the + // _process.h5 as /entry/MX/crystals, so a caller does not analyse the map a second time. + std::optional grid_scan; + // 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. diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 25c21b7ee..4c64825b3 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -45,7 +45,6 @@ #include "../image_analysis/scale_merge/TwinningAnalysis.h" #include "../image_analysis/scale_merge/TranslationalNCS.h" #include "../image_analysis/scale_merge/SearchSpaceGroup.h" -#include "../image_analysis/grid_scan_analysis/AnalyzeGridScan.h" #include "Rugnux.h" #include "RugnuxDefaults.h" #include "ModelValidation.h" @@ -2172,6 +2171,10 @@ static int RunRugnux(int argc, char **argv) { raster_settings.analysis = raster_analysis; if (beam_size_x_um) { raster_settings.beam_size_source = "COMMAND_LINE"; + // Onto the experiment, because that is where Rugnux - which sizes the crystals - reads + // it from. Same route as the analysis settings just below. + experiment.BeamSizeX_um(beam_size_x_um); + experiment.BeamSizeY_um(beam_size_y_um); } else { beam_size_x_um = experiment.GetBeamSizeX_um(); beam_size_y_um = experiment.GetBeamSizeY_um(); @@ -2198,9 +2201,9 @@ static int RunRugnux(int argc, char **argv) { config.run_scaling = false; config.write_merged = false; // The two raster files below are the output of this mode, so the per-image _process.h5 is - // asked for rather than written by default. Nothing indexes on this path, so RunPipeline's - // nothing-indexed fallback writes a small one anyway - which for a raster is the per-image - // scores, and is worth having. + // asked for rather than written by default. --write-process-h5 adds it, and it is what + // jfjoch_viewer opens: it carries the per-image scores, the grid scan itself and the + // crystals the run found (/entry/MX/crystals), which is the whole map on screen. config.write_process_h5 = write_process_h5_flag; experiment.ImportGridScanAnalysisSettings(raster_analysis); @@ -2237,10 +2240,9 @@ static int RunRugnux(int argc, char **argv) { } g_active_process = nullptr; - const GridScanResult crystals = AnalyzeGridScan(observer.scan, *grid, - raster_settings.beam_size_x_um, - raster_settings.beam_size_y_um, - raster_analysis); + // The run found them, from the same map, and wrote them into its _process.h5 - so the report + // and the file say the same thing rather than each analysing the raster on its own. + const GridScanResult crystals = result.grid_scan.value_or(GridScanResult{}); provenance.wall_time_s = std::chrono::duration( std::chrono::steady_clock::now() - invocation_start).count(); diff --git a/tests/AnalyzeGridScanTest.cpp b/tests/AnalyzeGridScanTest.cpp index dfa0c1d20..f6e7180e7 100644 --- a/tests/AnalyzeGridScanTest.cpp +++ b/tests/AnalyzeGridScanTest.cpp @@ -7,6 +7,8 @@ #include "../common/GridScanSettings.h" #include "../common/ScanResult.h" +#include "../common/ScanResultGenerator.h" +#include "../common/DiffractionExperiment.h" #include "../image_analysis/grid_scan_analysis/AnalyzeGridScan.h" namespace { @@ -354,3 +356,32 @@ TEST_CASE("AnalyzeGridScan falls back to the grid step for an unstated beam", "[ CHECK(stated.crystals[0].major_um == Catch::Approx(unstated.crystals[0].major_um)); CHECK(stated.crystals[0].minor_um == Catch::Approx(unstated.crystals[0].minor_um)); } + +TEST_CASE("ScanResultGenerator places a raster cell by its own image number", "[AnalyzeGridScan]") { + // The accumulator both front ends fill, feeding the analysis it is filled for. A receiver numbers + // images as it collects them, so a cell's ordinal and its place in the raster are the same there. + // rugnux numbers the images it SELECTED (-s/-e/--stride) and the two part company: only + // original_number says which cell of the map a frame actually came from. + const int64_t nx = 4, ny = 4, stride = 2; + GridScanSettings grid = MakeGrid(nx, ny, 10.0f, 10.0f); + + DiffractionExperiment experiment(DetJF(1)); + experiment.GridScan(grid); + + // Every second cell, the strong one being file image 10 - column 2, row 2 - which the loop + // handed over as its ordinal 5. + ScanResultGenerator generator(experiment); + for (int64_t ordinal = 0; ordinal * stride < nx * ny; ordinal++) { + DataMessage message{}; + message.number = ordinal; + message.original_number = ordinal * stride; + message.protein_score = (*message.original_number == 10) ? 0.9f : 0.05f; + generator.Add(message); + } + + const auto crystals = AnalyzeGridScan(generator.GetResult(), grid, 5.0f, 5.0f).crystals; + REQUIRE(crystals.size() == 1); + CHECK(crystals[0].image_number == 10); + CHECK(crystals[0].nx == Catch::Approx(2.0)); + CHECK(crystals[0].ny == Catch::Approx(2.0)); +} diff --git a/tests/RugnuxTest.cpp b/tests/RugnuxTest.cpp index 71e6098f3..ee5538d89 100644 --- a/tests/RugnuxTest.cpp +++ b/tests/RugnuxTest.cpp @@ -16,10 +16,13 @@ namespace { // Write a small VDS dataset of `n` flat images and return nothing (prefix_master.h5 + // prefix_data_000001.h5 land in the test working directory). - void WriteTestDataset(const std::string &prefix, int n) { + void WriteTestDataset(const std::string &prefix, int n, + const std::optional &grid = {}) { RegisterHDF5Filter(); DiffractionExperiment x(DetJF(1)); + if (grid) + x.GridScan(*grid); x.FilePrefix(prefix).ImagesPerTrigger(n).OverwriteExistingFiles(true); x.BitDepthImage(16).ImagesPerFile(n).SetFileWriterFormat(FileWriterFormat::NXmxVDS).PixelSigned(true); x.Compression(CompressionAlgorithm::NO_COMPRESSION); @@ -110,6 +113,56 @@ TEST_CASE("Rugnux_NoOutput", "[HDF5][Full]") { REQUIRE(H5Fget_obj_count(H5F_OBJ_ALL, H5F_OBJ_ALL) == 0); } +TEST_CASE("Rugnux_Grid", "[HDF5][Full]") { + // A grid-scan run analyses the completed map itself and writes what it found, so the crystals + // reach the file (and the viewer) rather than only the caller's own report. The grid scan has to + // survive into the _process.h5 as well: without it a re-opened run is an ordinary image series + // and the composite map is not even offered. + const int64_t nx = 4, ny = 4; + GridScanSettings grid(nx, 10.0f, 10.0f, false, false); + grid.ImageNum(nx * ny); + WriteTestDataset("process_grid_in", static_cast(nx * ny), grid); + + JFJochHDF5Reader reader; + REQUIRE_NOTHROW(reader.ReadFile("process_grid_in_master.h5")); + auto dataset = reader.GetDataset(); + REQUIRE(dataset); + REQUIRE(dataset->experiment.GetGridScan().has_value()); + + ProcessConfig config; + config.mode = AnalysisMode::Grid; + config.nthreads = 2; + config.output_prefix = "process_grid_out"; + config.spot_finding.enable = true; + config.spot_finding.indexing = false; + + Rugnux process(reader, dataset->experiment, *dataset->pixel_mask, config); + ProcessResult result; + REQUIRE_NOTHROW(result = process.Run()); + + CHECK_FALSE(result.cancelled); + CHECK(result.images_processed == nx * ny); + // The map was analysed - flat images hold no crystal, so the answer is an empty list, not none. + REQUIRE(result.grid_scan.has_value()); + CHECK(result.grid_scan->crystals.empty()); + REQUIRE(result.written_master_path.has_value()); + + { + JFJochHDF5Reader out; + REQUIRE_NOTHROW(out.ReadFile("process_grid_out_process.h5")); + auto out_dataset = out.GetDataset(); + REQUIRE(out_dataset); + REQUIRE(out_dataset->experiment.GetGridScan().has_value()); + CHECK(out_dataset->experiment.GetGridScan()->GetNFast() == nx); + } + + reader.Close(); + remove("process_grid_in_master.h5"); + remove("process_grid_in_data_000001.h5"); + remove("process_grid_out_process.h5"); + REQUIRE(H5Fget_obj_count(H5F_OBJ_ALL, H5F_OBJ_ALL) == 0); +} + TEST_CASE("Rugnux_Cancel", "[HDF5][Full]") { WriteTestDataset("process_cancel_in", 8); diff --git a/viewer/widgets/JFJochViewerSettingsDock.cpp b/viewer/widgets/JFJochViewerSettingsDock.cpp index 4f8511821..ffad3f6a9 100644 --- a/viewer/widgets/JFJochViewerSettingsDock.cpp +++ b/viewer/widgets/JFJochViewerSettingsDock.cpp @@ -89,7 +89,7 @@ JFJochViewerSettingsDock::JFJochViewerSettingsDock(const SpotFindingSettings &sp " image / settings change while active"); analyzeDatasetBtn_ = new QPushButton(FramesIcon(3), " Analyze dataset", this); analyzeDatasetBtn_->setStyleSheet(heroStyle); - analyzeDatasetBtn_->setToolTip("Process the whole dataset (MX, azimuthal or calibration, per the toggle below)"); + analyzeDatasetBtn_->setToolTip("Process the whole dataset (MX, azimuthal, calibration or grid scan, per the toggle below)"); auto *analyzeRow = new QHBoxLayout(); analyzeRow->addWidget(analyzeImageBtn); analyzeRow->addWidget(analyzeDatasetBtn_); @@ -99,12 +99,15 @@ JFJochViewerSettingsDock::JFJochViewerSettingsDock(const SpotFindingSettings &sp connect(analyzeDatasetBtn_, &QPushButton::clicked, this, [this] { emit analyzeDataset(mode_, powder_->Selection()); }); - // Segmented MX / AzInt / Calib toggle: each community picks its page; pages never share a screen. - // The page is also the mode "Analyze dataset" runs, so there is one control, not two. + // Segmented MX / AzInt / Calib / Grid toggle: each community picks its page; pages never share a + // screen. The page is also the mode "Analyze dataset" runs, so there is one control, not two. auto *mxButton = new QPushButton("MX", this); auto *azButton = new QPushButton("AzInt", this); auto *calibButton = new QPushButton("Calib", this); - for (auto *b : {mxButton, azButton, calibButton}) { + auto *gridButton = new QPushButton("Grid", this); + gridButton->setToolTip("Grid scan: score every cell of a raster and find the crystals in it. " + "Needs a dataset collected as a grid scan."); + for (auto *b : {mxButton, azButton, calibButton, gridButton}) { b->setCheckable(true); b->setStyleSheet("QPushButton:checked { background-color: #1F3A5F; color: white; }"); } @@ -114,27 +117,32 @@ JFJochViewerSettingsDock::JFJochViewerSettingsDock(const SpotFindingSettings &sp group->addButton(mxButton, 0); group->addButton(azButton, 1); group->addButton(calibButton, 2); + group->addButton(gridButton, 3); auto *toggleRow = new QHBoxLayout(); toggleRow->setSpacing(0); toggleRow->addWidget(mxButton); toggleRow->addWidget(azButton); toggleRow->addWidget(calibButton); + toggleRow->addWidget(gridButton); auto *stack = new QStackedWidget(this); stack->addWidget(BuildMXPage()); stack->addWidget(new QWidget(stack)); // AzInt: the shared section below is the whole page stack->addWidget(BuildCalibrationPage()); + stack->addWidget(new QWidget(stack)); // Grid: scored with the shared azimuthal section connect(group, &QButtonGroup::idClicked, this, [this, stack](int id) { mode_ = id == 1 ? AnalysisMode::Azint - : id == 2 ? AnalysisMode::PowderCalibration : AnalysisMode::MXStills; + : id == 2 ? AnalysisMode::PowderCalibration + : id == 3 ? AnalysisMode::Grid : AnalysisMode::MXStills; stack->setCurrentIndex(id); azintSection_->setVisible(id != 0); }); - // Geometry is common to all three, so it lives above the toggle rather than per page. Azimuthal - // integration is common to two of them - a calibration by rings integrates the run in azimuthal - // sectors, and needs the same Q range and spacing - so it is shared the same way and hidden on MX. + // Geometry is common to all four, so it lives above the toggle rather than per page. Azimuthal + // integration is common to three of them - a calibration by rings integrates the run in azimuthal + // sectors and a grid scan's ice score reads the radial profile, and all of them need the same Q + // range and spacing - so it is shared the same way and hidden only on MX. layout->addLayout(toggleRow); layout->addWidget(BuildGeometrySection()); azintSection_ = BuildAzIntSection(); @@ -148,7 +156,7 @@ void JFJochViewerSettingsDock::setHttpConnection(bool connected, QString) { analyzeDatasetBtn_->setEnabled(!connected); analyzeDatasetBtn_->setToolTip(connected ? "Dataset re-processing is only available for an open file, not a live HTTP stream" - : "Process the whole dataset (MX, azimuthal or calibration, per the toggle below)"); + : "Process the whole dataset (MX, azimuthal, calibration or grid scan, per the toggle below)"); } QWidget *JFJochViewerSettingsDock::BuildGeometrySection() { diff --git a/viewer/windows/JFJochProcessingJobsWindow.cpp b/viewer/windows/JFJochProcessingJobsWindow.cpp index 6edf8e569..6a95801da 100644 --- a/viewer/windows/JFJochProcessingJobsWindow.cpp +++ b/viewer/windows/JFJochProcessingJobsWindow.cpp @@ -145,7 +145,9 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec // the run options. const bool azint = spec.mode == AnalysisMode::Azint; const bool calibration = spec.mode == AnalysisMode::PowderCalibration; + const bool grid = spec.mode == AnalysisMode::Grid; dlg.setWindowTitle(calibration ? "New detector-calibration job" + : grid ? "New grid-scan job" : azint ? "New azimuthal-integration job" : "New full-analysis job"); auto *start_image = new QSpinBox(&dlg); @@ -166,11 +168,13 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec // 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(!calibration); - save_h5->setEnabled(!calibration); // a calibration's output is the .poni, not per-image results + // A calibration's output is the .poni, not per-image results. A grid scan's IS the per-image file - + // the map and the crystals it found are read back out of it - so there it is not optional. + save_h5->setEnabled(!calibration && !grid); auto *save_merged = new QCheckBox("Write merged .mtz/.cif", &dlg); save_merged->setChecked(true); - save_merged->setEnabled(!azint && !calibration); // merged output only in full analysis + save_merged->setEnabled(!azint && !calibration && !grid); // merged output only in full analysis // 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, @@ -204,7 +208,7 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec // analysis. auto *scaling = new QCheckBox("Scale && merge", &dlg); scaling->setChecked(true); - scaling->setEnabled(!azint && !calibration); + scaling->setEnabled(!azint && !calibration && !grid); // Stills geometry-refinement two-pass: an extra first pass bundle-adjusts the shared beam/distance/ // cell from the strongest indexed frames, then the main pass re-indexes with it. It is stills-only @@ -387,6 +391,13 @@ ProcessConfig JFJochProcessingJobsWindow::buildConfig(const JobSpec &spec, const config.spot_finding.enable = true; config.spot_finding.indexing = false; } + if (spec.mode == AnalysisMode::Grid) { + // Spot finding runs, because the per-cell resolution comes from it. Whether each cell is also + // indexed is a property of the experiment, not of rasters (GridScanAnalysisSettings), which is + // what the CLI asks too. Nothing scales or merges: a raster answers where the crystals are. + config.spot_finding.enable = true; + config.spot_finding.indexing = inputs.experiment.GetGridScanAnalysisSettings().IsIndexing(); + } if (AnalysisModeIsMX(spec.mode)) { // Rotation indexing follows the panel's rotation axis (= the experiment's indexing // setting); a rotation run uses 60 first-pass images to find the lattice. @@ -413,6 +424,15 @@ void JFJochProcessingJobsWindow::newJob(AnalysisMode mode, CalibrationSelection return; } + // A grid scan is the whole point of the mode: without one there is no map to find crystals in, + // and a run would score every image and report nothing. + if (mode == AnalysisMode::Grid && !inputs.experiment.GetGridScan().has_value()) { + QMessageBox::information(this, "Processing", + "This dataset was not collected as a grid scan, so there is no raster " + "to find crystals in. Pick another mode in the settings panel."); + return; + } + JobSpec spec; spec.mode = mode; spec.calibration = std::move(calibration);