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);