diff --git a/broker/JFJochBrokerHttp.cpp b/broker/JFJochBrokerHttp.cpp index d97fc7e94..2fdd17090 100644 --- a/broker/JFJochBrokerHttp.cpp +++ b/broker/JFJochBrokerHttp.cpp @@ -206,6 +206,11 @@ void JFJochBrokerHttp::register_routes(httplib::Server &server) { server.Put("/config/bragg_integration", bind_json(&JFJochBrokerHttp::config_bragg_integration_put, Bragg_integration_settings{})); server.Get("/config/analysis", bind_noarg(&JFJochBrokerHttp::config_analysis_get)); server.Put("/config/analysis", bind_json(&JFJochBrokerHttp::config_analysis_put, Analysis_settings{})); + server.Get("/config/grid_scan_analysis", bind_noarg(&JFJochBrokerHttp::config_grid_scan_analysis_get)); + server.Put("/config/grid_scan_analysis", + bind_json(&JFJochBrokerHttp::config_grid_scan_analysis_put, Grid_scan_analysis_settings{})); + server.Get("/config/calibration", bind_noarg(&JFJochBrokerHttp::config_calibration_get)); + server.Put("/config/calibration", bind_json(&JFJochBrokerHttp::config_calibration_put, Calibration_settings{})); server.Get("/config/instrument", bind_noarg(&JFJochBrokerHttp::config_instrument_get)); server.Put("/config/instrument", bind_json(&JFJochBrokerHttp::config_instrument_put, Instrument_metadata{})); server.Put("/config/internal_generator_image", [this](const httplib::Request &req, httplib::Response &res) { @@ -604,6 +609,8 @@ void JFJochBrokerHttp::statistics_get(httplib::Response &response) { statistics.setIndexing(Convert(state_machine.GetIndexingSettings())); statistics.setBraggIntegration(Convert(state_machine.GetBraggIntegrationSettings())); statistics.setAnalysis(Convert(state_machine.GetAnalysisSettings())); + statistics.setGridScanAnalysis(Convert(state_machine.GetGridScanAnalysisSettings())); + statistics.setCalibrationSettings(Convert(state_machine.GetCalibrationSettings())); statistics.setDarkMask(Convert(state_machine.GetDarkMaskSettings())); statistics.setImagePusher(Convert(state_machine.GetImagePusherStatus())); @@ -858,6 +865,26 @@ void JFJochBrokerHttp::config_analysis_put(const Analysis_settings &analysisSett response.status = 200; } +void JFJochBrokerHttp::config_grid_scan_analysis_get(httplib::Response &response) { + ProcessOutput(Convert(state_machine.GetGridScanAnalysisSettings()), response); +} + +void JFJochBrokerHttp::config_grid_scan_analysis_put(const Grid_scan_analysis_settings &gridScanAnalysisSettings, + httplib::Response &response) { + state_machine.SetGridScanAnalysisSettings(Convert(gridScanAnalysisSettings)); + response.status = 200; +} + +void JFJochBrokerHttp::config_calibration_get(httplib::Response &response) { + ProcessOutput(Convert(state_machine.GetCalibrationSettings()), response); +} + +void JFJochBrokerHttp::config_calibration_put(const Calibration_settings &calibrationSettings, + httplib::Response &response) { + state_machine.SetCalibrationSettings(Convert(calibrationSettings)); + response.status = 200; +} + void JFJochBrokerHttp::result_scan_get(httplib::Response &response) { auto ret = state_machine.GetScanResult(); if (ret.has_value()) diff --git a/broker/JFJochBrokerHttp.h b/broker/JFJochBrokerHttp.h index d268c5c4a..a3b52f311 100644 --- a/broker/JFJochBrokerHttp.h +++ b/broker/JFJochBrokerHttp.h @@ -184,6 +184,13 @@ class JFJochBrokerHttp { void config_analysis_get(httplib::Response &response); void config_analysis_put(const org::openapitools::server::model::Analysis_settings &analysisSettings, httplib::Response &response); + void config_grid_scan_analysis_get(httplib::Response &response); + void config_grid_scan_analysis_put( + const org::openapitools::server::model::Grid_scan_analysis_settings &gridScanAnalysisSettings, + httplib::Response &response); + void config_calibration_get(httplib::Response &response); + void config_calibration_put(const org::openapitools::server::model::Calibration_settings &calibrationSettings, + httplib::Response &response); void config_dark_mask_get(httplib::Response &response); void config_dark_mask_put(const org::openapitools::server::model::Dark_mask_settings &darkMaskSettings, diff --git a/broker/JFJochBrokerParser.cpp b/broker/JFJochBrokerParser.cpp index 1daee2620..b93d474ec 100644 --- a/broker/JFJochBrokerParser.cpp +++ b/broker/JFJochBrokerParser.cpp @@ -194,6 +194,14 @@ void ParseFacilityConfiguration(const org::openapitools::server::model::Jfjoch_s experiment.ImportAnalysisSettings(analysis); } + if (j.gridScanAnalysisIsSet()) + experiment.ImportGridScanAnalysisSettings(Convert(j.getGridScanAnalysis())); + + // After the mode, so that selecting PowderCalibration in the same file still lands the azimuthal + // integration the fit needs. + if (j.calibrationSettingsIsSet()) + experiment.ImportCalibrationSettings(Convert(j.getCalibrationSettings())); + if (j.darkMaskIsSet()) experiment.ImportDarkMaskSettings(Convert(j.getDarkMask())); } diff --git a/broker/JFJochStateMachine.cpp b/broker/JFJochStateMachine.cpp index e8b3f7ce9..e17bd9e8d 100644 --- a/broker/JFJochStateMachine.cpp +++ b/broker/JFJochStateMachine.cpp @@ -1240,6 +1240,34 @@ void JFJochStateMachine::SetAnalysisSettings(const AnalysisSettings &input) { experiment.ImportAnalysisSettings(input); } +GridScanAnalysisSettings JFJochStateMachine::GetGridScanAnalysisSettings() const { + std::unique_lock ul(experiment_indexing_settings_mutex); + return experiment.GetGridScanAnalysisSettings(); +} + +void JFJochStateMachine::SetGridScanAnalysisSettings(const GridScanAnalysisSettings &input) { + std::unique_lock ul(m); + if (IsRunning()) + throw WrongDAQStateException("Cannot change grid scan analysis settings during data collection"); + + std::unique_lock ul2(experiment_indexing_settings_mutex); + experiment.ImportGridScanAnalysisSettings(input); +} + +CalibrationSettings JFJochStateMachine::GetCalibrationSettings() const { + std::unique_lock ul(experiment_indexing_settings_mutex); + return experiment.GetCalibrationSettings(); +} + +void JFJochStateMachine::SetCalibrationSettings(const CalibrationSettings &input) { + std::unique_lock ul(m); + if (IsRunning()) + throw WrongDAQStateException("Cannot change calibration settings during data collection"); + + std::unique_lock ul2(experiment_indexing_settings_mutex); + experiment.ImportCalibrationSettings(input); +} + std::optional JFJochStateMachine::GetScanResult() const { std::unique_lock ul(m); if (IsRunning()) diff --git a/broker/JFJochStateMachine.h b/broker/JFJochStateMachine.h index 2c2a46a00..46adcb983 100644 --- a/broker/JFJochStateMachine.h +++ b/broker/JFJochStateMachine.h @@ -252,6 +252,12 @@ public: void SetAnalysisSettings(const AnalysisSettings &input); AnalysisSettings GetAnalysisSettings() const; + + void SetGridScanAnalysisSettings(const GridScanAnalysisSettings &input); + GridScanAnalysisSettings GetGridScanAnalysisSettings() const; + + void SetCalibrationSettings(const CalibrationSettings &input); + CalibrationSettings GetCalibrationSettings() const; PixelMaskStatistics GetPixelMaskStatistics() const; void GetStartMessageFromBuffer(std::vector &v); diff --git a/broker/OpenAPIConvert.cpp b/broker/OpenAPIConvert.cpp index 8ef834086..636a6dd8b 100644 --- a/broker/OpenAPIConvert.cpp +++ b/broker/OpenAPIConvert.cpp @@ -1146,9 +1146,6 @@ AnalysisSettings Convert(const org::openapitools::server::model::Analysis_settin break; } - if (input.calibrantIsSet()) - ret.Calibrant(input.getCalibrant()); - return ret; } @@ -1179,6 +1176,61 @@ org::openapitools::server::model::Analysis_settings Convert(const AnalysisSettin "Rotation MX analysis cannot be reported by this API"); } ret.setMode(tmp); + return ret; +} + +GridScanAnalysisSettings Convert(const org::openapitools::server::model::Grid_scan_analysis_settings &input) { + GridScanAnalysisSettings ret; + ret.ProteinScoreThreshold(input.getProteinScoreThreshold()); + ret.MinBlobCells(input.getMinBlobCells()); + ret.DecisiveSingleCellScore(input.getDecisiveSingleCellScore()); + ret.MaxCrystals(input.getMaxCrystals()); + ret.Indexing(input.isIndexing()); + return ret; +} + +org::openapitools::server::model::Grid_scan_analysis_settings Convert(const GridScanAnalysisSettings &input) { + org::openapitools::server::model::Grid_scan_analysis_settings ret; + ret.setProteinScoreThreshold(input.GetProteinScoreThreshold()); + ret.setMinBlobCells(input.GetMinBlobCells()); + ret.setDecisiveSingleCellScore(input.GetDecisiveSingleCellScore()); + ret.setMaxCrystals(input.GetMaxCrystals()); + ret.setIndexing(input.IsIndexing()); + return ret; +} + +CalibrationSettings Convert(const org::openapitools::server::model::Calibration_settings &input) { + CalibrationSettings ret; + // No default: the compiler must object here when a source is added to the API. + switch (input.getMethod().getValue()) { + case org::openapitools::server::model::Calibration_method::eCalibration_method::INVALID_VALUE_OPENAPI_GENERATED: + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "Unknown calibration method"); + case org::openapitools::server::model::Calibration_method::eCalibration_method::RINGS: + ret.Method(CalibrationMethod::Rings); + break; + case org::openapitools::server::model::Calibration_method::eCalibration_method::SPOTS: + ret.Method(CalibrationMethod::Spots); + break; + } + + if (input.calibrantIsSet()) + ret.Calibrant(input.getCalibrant()); + + return ret; +} + +org::openapitools::server::model::Calibration_settings Convert(const CalibrationSettings &input) { + org::openapitools::server::model::Calibration_settings ret; + org::openapitools::server::model::Calibration_method tmp; + switch (input.GetMethod()) { + case CalibrationMethod::Rings: + tmp.setValue(org::openapitools::server::model::Calibration_method::eCalibration_method::RINGS); + break; + case CalibrationMethod::Spots: + tmp.setValue(org::openapitools::server::model::Calibration_method::eCalibration_method::SPOTS); + break; + } + ret.setMethod(tmp); if (!input.GetCalibrant().empty()) ret.setCalibrant(input.GetCalibrant()); return ret; diff --git a/broker/OpenAPIConvert.h b/broker/OpenAPIConvert.h index 7d0df3d57..e7e6e98f3 100644 --- a/broker/OpenAPIConvert.h +++ b/broker/OpenAPIConvert.h @@ -30,6 +30,8 @@ #include "gen/model/Indexing_settings.h" #include "gen/model/Bragg_integration_settings.h" #include "gen/model/Analysis_settings.h" +#include "gen/model/Grid_scan_analysis_settings.h" +#include "gen/model/Calibration_settings.h" #include "gen/model/Grid_scan_result.h" #include "gen/model/Powder_calibration_output.h" #include "gen/model/Scan_result.h" @@ -44,6 +46,8 @@ #include "../common/InstrumentMetadata.h" #include "../common/ScanResult.h" #include "../common/AnalysisSettings.h" +#include "../common/GridScanAnalysisSettings.h" +#include "../common/CalibrationSettings.h" #include "../image_pusher/ImagePusher.h" SpotFindingSettings Convert(const org::openapitools::server::model::Spot_finding_settings &input); @@ -58,6 +62,12 @@ org::openapitools::server::model::Bragg_integration_settings Convert(const Bragg AnalysisSettings Convert(const org::openapitools::server::model::Analysis_settings &input); org::openapitools::server::model::Analysis_settings Convert(const AnalysisSettings &input); +GridScanAnalysisSettings Convert(const org::openapitools::server::model::Grid_scan_analysis_settings &input); +org::openapitools::server::model::Grid_scan_analysis_settings Convert(const GridScanAnalysisSettings &input); + +CalibrationSettings Convert(const org::openapitools::server::model::Calibration_settings &input); +org::openapitools::server::model::Calibration_settings Convert(const CalibrationSettings &input); + org::openapitools::server::model::Grid_scan_crystal Convert(const GridScanCrystal &input); org::openapitools::server::model::Powder_calibration_output Convert(const CalibrationResult &input); diff --git a/broker/jfjoch_api.yaml b/broker/jfjoch_api.yaml index 52b5a5b6b..96877591c 100644 --- a/broker/jfjoch_api.yaml +++ b/broker/jfjoch_api.yaml @@ -1991,6 +1991,10 @@ components: $ref: '#/components/schemas/bragg_integration_settings' analysis: $ref: '#/components/schemas/analysis_settings' + grid_scan_analysis: + $ref: '#/components/schemas/grid_scan_analysis_settings' + calibration_settings: + $ref: '#/components/schemas/calibration_settings' image_pusher: $ref: '#/components/schemas/image_pusher_status' error_message: @@ -2621,18 +2625,109 @@ components: analysis_settings: type: object description: | - The analysis mode and the settings that only mean anything under one of them. Persistent: it - survives a data collection, unlike the per-dataset settings given to /start. + What every analysis method shares, and nothing else. Each method's own parameters have their + own settings object and their own endpoint - grid_scan_analysis_settings + (/config/grid_scan_analysis), calibration_settings (/config/calibration) - so that adding a + parameter to one method does not widen the structure every method reads. + + Persistent: it survives a data collection, unlike the per-dataset settings given to /start. required: - mode properties: mode: $ref: '#/components/schemas/analysis_mode' + calibration_method: + type: string + description: | + How the powder rings the detector geometry is fitted to are measured. + Rings - the run-summed (q x azimuth) azimuthal profile: the ring measured at every azimuth. + Spots - the pooled per-image spot lists: the ring sampled wherever the spot finder bit. + enum: + - "Rings" + - "Spots" + default: "Rings" + calibration_settings: + type: object + description: | + Settings for analysis mode PowderCalibration and nothing else. + + Selecting that mode also moves azimuthal integration onto the CPU and, where fewer than four + azimuthal sectors were asked for, sets a usable count. Ring positions are read off a + (q x azimuth) profile, and the FPGA integration core holds 2048 bins in total - at 32 sectors + that leaves 64 q bins, far too coarse to locate a ring. The frame rate this costs is not frame + rate a calibration exposure needs. + required: + - method + properties: calibrant: type: string description: | - PowderCalibration only - which calibrant's ring d-spacings the geometry is fitted against. - Ignored under every other mode. + Which calibrant's ring d-spacings the geometry is fitted against. Empty selects the + processing program's own default rather than meaning "no calibrant". + method: + $ref: '#/components/schemas/calibration_method' + grid_scan_analysis_settings: + type: object + description: | + Settings for analysis mode Grid and nothing else: how the per-image protein score is turned + into a list of crystals, and whether the raster indexes as well as scores. + required: + - protein_score_threshold + - min_blob_cells + - decisive_single_cell_score + - max_crystals + - indexing + properties: + protein_score_threshold: + type: number + format: float + minimum: 0 + maximum: 1 + default: 0.5 + description: | + A raster cell counts as protein above this. The per-image protein score saturates, so this + only has to separate "something diffracted here" from "nothing did". + min_blob_cells: + type: integer + format: int64 + minimum: 1 + default: 3 + description: | + How many cells above that threshold make a shape rather than a coincidence. Two cells can + be the two ends of a single hit lying on a cell boundary; three is the smallest patch that + is not. + decisive_single_cell_score: + type: number + format: float + minimum: 0 + maximum: 1 + default: 0.9 + description: | + ...unless one cell on its own is decisive. The rule above is about coincidences, and a + lone cell scoring near the top of a saturating score is not one - a crystal smaller than + the grid step lights exactly one cell, and refusing it would lose precisely the samples a + fine raster is run to find. Set well above protein_score_threshold: this admits the + obvious case, it does not lower the general threshold by the back door. + max_crystals: + type: integer + format: int64 + minimum: 1 + default: 10 + description: | + Most crystals reported. A raster over a loop full of shards can label dozens of blobs, and + past the first few the list is no longer a ranking anyone acts on. Crystals are sorted by + score, so this keeps the best. + indexing: + type: boolean + default: true + description: | + Whether each raster cell is indexed as well as scored. On by default: a raster runs at up + to 100 Hz, which the FFT indexer keeps up with, and it is additive - blobs are still found + on the protein score, so indexing changes nothing about which cells are called crystals + and only adds what was found in them. The lattice count per cell is the cheapest + multi-lattice or cracked-crystal signal there is, and on a fixed-target serial experiment + with a known cell a raster that indexes is most of the measurement. Turn it off for a very + large raster where the GPU is the constraint. bragg_integration_settings: type: object description: "Settings for Bragg spot integration" @@ -2687,6 +2782,10 @@ components: $ref: '#/components/schemas/bragg_integration_settings' analysis: $ref: '#/components/schemas/analysis_settings' + grid_scan_analysis: + $ref: '#/components/schemas/grid_scan_analysis_settings' + calibration_settings: + $ref: '#/components/schemas/calibration_settings' detector_settings: $ref: '#/components/schemas/detector_settings' azim_int: @@ -3104,6 +3203,80 @@ paths: application/json: schema: $ref: '#/components/schemas/analysis_settings' + /config/grid_scan_analysis: + put: + summary: Change grid scan analysis settings + description: | + This can only be done when detector is `Idle`, `Error` or `Inactive` states. + Persistent - not reset by a data collection. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/grid_scan_analysis_settings' + responses: + "200": + description: Everything OK + "400": + description: Input parsing or validation error + content: + text/plain: + schema: + type: string + description: Exception error + "500": + description: Error within Jungfraujoch code - see output message. + content: + application/json: + schema: + $ref: '#/components/schemas/error_message' + get: + summary: Get grid scan analysis configuration + description: Can be done anytime + responses: + "200": + description: Everything OK + content: + application/json: + schema: + $ref: '#/components/schemas/grid_scan_analysis_settings' + /config/calibration: + put: + summary: Change powder calibration settings + description: | + This can only be done when detector is `Idle`, `Error` or `Inactive` states. + Persistent - not reset by a data collection. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/calibration_settings' + responses: + "200": + description: Everything OK + "400": + description: Input parsing or validation error + content: + text/plain: + schema: + type: string + description: Exception error + "500": + description: Error within Jungfraujoch code - see output message. + content: + application/json: + schema: + $ref: '#/components/schemas/error_message' + get: + summary: Get powder calibration configuration + description: Can be done anytime + responses: + "200": + description: Everything OK + content: + application/json: + schema: + $ref: '#/components/schemas/calibration_settings' /config/file_writer: put: summary: Change file writer settings diff --git a/common/AnalysisSettings.cpp b/common/AnalysisSettings.cpp index f76d93b4f..1e1be5a89 100644 --- a/common/AnalysisSettings.cpp +++ b/common/AnalysisSettings.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only #include "AnalysisSettings.h" +#include "GridScanAnalysisSettings.h" // The mode-to-analysis table. One place, one row per mode, and every gate in the pipeline reads it - // so what a mode does can be read off here instead of being reconstructed from `if (mode == ...)` @@ -12,7 +13,7 @@ // MXRotation x x x x x x // MXStills x x x x x x // Azint - - - - - x -// Grid x - - - x x +// Grid x * - - x x * = default; a setting // PowderCalibration x - - - - x // // Two rows want a word. @@ -21,11 +22,12 @@ // (q x azimuth) profile or to the POOLED SPOTS (rugnux --calibration rings|spots), and the second of // those is a spot finder run - so the mode must not decide this; whoever picks the source does. // -// Grid does not index. A raster is thousands of frames and indexing is the expensive stage, and the -// per-image scoring it ranks grid points on deliberately does not use it (indexing fires on ice, which -// is exactly what the scoring has to see past). The cost of that choice is indexed_lattice_count, which -// is the cheapest multi-lattice / cracked-crystal signal there is and which a raster therefore does not -// get. It is one field in this table either way. +// Grid indexes by default, but that entry is not the mode's to fix: it is +// GridScanAnalysisSettings::indexing, and what stands here is only what that setting defaults to. A +// raster runs at up to 100 Hz, which the FFT indexer keeps up with, and a fixed-target serial +// experiment with a known cell wants ffbidx on every cell - so whether a raster indexes is a property +// of the experiment, not of rasters. DiffractionExperiment::GetAnalysisStages substitutes the +// configured value. // // The switch has no default: adding a mode must be a compile error here rather than a silent // fall-through to whatever the last row happened to be. @@ -39,7 +41,7 @@ AnalysisStages AnalysisModeStages(AnalysisMode mode) { case AnalysisMode::Azint: return {false, false, false, false, false, true}; case AnalysisMode::Grid: - return {true, false, false, false, true, true}; + return {true, GridScanAnalysisSettings().IsIndexing(), false, false, true, true}; case AnalysisMode::PowderCalibration: return {true, false, false, false, false, true}; } @@ -87,19 +89,10 @@ AnalysisSettings &AnalysisSettings::Mode(AnalysisMode input) { return *this; } -AnalysisSettings &AnalysisSettings::Calibrant(const std::string &input) { - calibrant = input; - return *this; -} - AnalysisMode AnalysisSettings::GetMode() const { return mode; } -const std::string &AnalysisSettings::GetCalibrant() const { - return calibrant; -} - bool AnalysisSettings::IsMX() const { return AnalysisModeIsMX(mode); } diff --git a/common/AnalysisSettings.h b/common/AnalysisSettings.h index f8e964dcf..095ba8e74 100644 --- a/common/AnalysisSettings.h +++ b/common/AnalysisSettings.h @@ -31,25 +31,15 @@ enum class AnalysisMode { PowderCalibration // detector geometry from a calibrant's powder rings }; -// Azimuthal sectors a powder calibration integrates into when nothing else is asked for. A plain -// radial profile (1 sector) has averaged every ring over every direction, so nothing is left to say -// where its centre is (RingsFromAzimuthalProfile refuses below 4). Sweeping the count on a LaB6 -// exposure and on a crystal's ice rings, 32 and above agree to about 0.1 px with a flat residual, -// while 8 sectors is visibly coarser (0.3 px away, twice the residual). -// -// 32 is also what the FPGA integration core can carry: it holds FPGA_INTEGRATION_BIN_COUNT = 2048 -// bins in total (the receiver throws above that), so 32 sectors would leave 64 q bins - far too -// coarse to fit a ring. PowderCalibration therefore forces azimuthal integration onto the CPU -// (AzimuthalIntegrationSettings::ForceCPUinFPGAWorkflow), which lifts the bin limit at the cost of -// frame rate. That is not a problem this mode has: a calibration exposure is a few images at a few -// Hz, not a data collection. -constexpr int CALIBRATION_AZIM_BINS_DEFAULT = 32; - // Which stages of the pipeline a mode runs. This is the whole point of the mode: it does not label a // run, it decides what happens in it, and every gate in the pipeline reads this rather than testing // the mode itself. The table is in AnalysisSettings.cpp - modes are the rows, stages the columns. struct AnalysisStages { bool spot_finding; + // Fixed by the mode everywhere except Grid, where it is a setting + // (GridScanAnalysisSettings::indexing). AnalysisModeStages carries the grid DEFAULT; + // DiffractionExperiment::GetAnalysisStages is where the configured value takes over, and that is + // what every gate reads. bool indexing; // Bragg prediction and integration. It follows indexing in every engine - nothing is predicted // without a lattice - so this never says yes where indexing says no. @@ -72,20 +62,18 @@ bool AnalysisModeIsMX(AnalysisMode mode); std::string AnalysisModeName(AnalysisMode mode); std::optional AnalysisModeFromName(std::string_view name); -// The analysis mode and the settings that only mean anything under one of them. Persistent, not -// per-dataset: it sits on DiffractionExperiment outside the DatasetSettings member, which is the one -// thing a /start replaces wholesale. +// What every analysis method shares, and nothing else. Each method's own knobs live in its own class +// beside this one - GridScanAnalysisSettings, CalibrationSettings - so that adding a parameter to one +// method cannot widen the structure every method reads. +// +// Persistent, not per-dataset: it sits on DiffractionExperiment outside the DatasetSettings member, +// which is the one thing a /start replaces wholesale. The per-method classes are bound the same way. class AnalysisSettings { AnalysisMode mode = AnalysisMode::MXStills; - // PowderCalibration only: which calibrant's ring d-spacings to fit against. - std::string calibrant; - // Grid decision thresholds land here when Grid is implemented. public: AnalysisSettings& Mode(AnalysisMode input); - AnalysisSettings& Calibrant(const std::string &input); [[nodiscard]] AnalysisMode GetMode() const; - [[nodiscard]] const std::string &GetCalibrant() const; [[nodiscard]] bool IsMX() const; }; diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt index 70ae1a4d9..41bed61d6 100644 --- a/common/CMakeLists.txt +++ b/common/CMakeLists.txt @@ -123,6 +123,10 @@ ADD_LIBRARY(JFJochCommon STATIC BraggIntegrationSettings.h AnalysisSettings.cpp AnalysisSettings.h + GridScanAnalysisSettings.cpp + GridScanAnalysisSettings.h + CalibrationSettings.cpp + CalibrationSettings.h SpotToSave.cpp XrayFluorescenceSpectrum.cpp XrayFluorescenceSpectrum.h diff --git a/common/CalibrationSettings.cpp b/common/CalibrationSettings.cpp new file mode 100644 index 000000000..929bee5d7 --- /dev/null +++ b/common/CalibrationSettings.cpp @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include "CalibrationSettings.h" + +CalibrationSettings &CalibrationSettings::Calibrant(const std::string &input) { + calibrant = input; + return *this; +} + +CalibrationSettings &CalibrationSettings::Method(CalibrationMethod input) { + method = input; + return *this; +} + +const std::string &CalibrationSettings::GetCalibrant() const { + return calibrant; +} + +CalibrationMethod CalibrationSettings::GetMethod() const { + return method; +} + +void CalibrationSettings::ApplyToAzimuthalIntegration(AzimuthalIntegrationSettings &azint) const { + azint.ForceCPUinFPGAWorkflow(true); + if (azint.GetAzimuthalBinCount() < 4) + azint.AzimuthalBinCount(CALIBRATION_AZIM_BINS_DEFAULT); +} diff --git a/common/CalibrationSettings.h b/common/CalibrationSettings.h new file mode 100644 index 000000000..42ef5229d --- /dev/null +++ b/common/CalibrationSettings.h @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include + +#include "AzimuthalIntegrationSettings.h" + +// How the powder rings the detector geometry is fitted to are measured (rugnux --calibration). +// Lives here rather than beside the fit itself so that the setting and the code that consumes it are +// not two different vocabularies; image_analysis/geom_refinement/PowderCalibration.h includes this. +enum class CalibrationMethod { + Rings, // the run-summed (q x azimuth) azimuthal profile: the ring measured at every azimuth + Spots // the pooled per-image spot lists: the ring sampled wherever the spot finder bit +}; + +// Azimuthal sectors a powder calibration integrates into when nothing usable was asked for. A plain +// radial profile has averaged every ring over every direction, so nothing is left to say where its +// centre is (RingsFromAzimuthalProfile refuses below 4), and the azimuthal default of one sector is +// exactly that. Sweeping the count on a LaB6 exposure and on a crystal's ice rings, 32 and above +// agree to about 0.1 px with a flat residual, while 8 sectors is visibly coarser (0.3 px away, twice +// the residual). +constexpr int CALIBRATION_AZIM_BINS_DEFAULT = 32; + +// Settings for AnalysisMode::PowderCalibration and nothing else. Per-method by the same rule as +// GridScanAnalysisSettings: AnalysisSettings carries what every method shares, each method carries +// its own knobs. +class CalibrationSettings { + // Which calibrant's ring d-spacings the geometry is fitted against. Empty means the front end's + // own default (rugnux --calibrant) rather than "no calibrant". + std::string calibrant; + CalibrationMethod method = CalibrationMethod::Rings; +public: + CalibrationSettings& Calibrant(const std::string &input); + CalibrationSettings& Method(CalibrationMethod input); + + [[nodiscard]] const std::string &GetCalibrant() const; + [[nodiscard]] CalibrationMethod GetMethod() const; + + // The azimuthal integration a powder calibration needs, applied to whatever is configured. + // + // Ring positions are read off a (q x azimuth) profile, so the integration needs azimuthal sectors + // AND enough q bins under each of them to place a ring. The FPGA integration core holds + // FPGA_INTEGRATION_BIN_COUNT = 2048 bins in TOTAL, which at 32 sectors leaves 64 q bins - far too + // coarse to locate a ring - so the integration is moved onto the CPU, where that limit does not + // apply. This is not a defect on the FPGA path to be fixed back onto it: the core is sized for a + // detector running at full rate, and a calibration exposure is a few images at a few Hz, so the + // frame rate this costs is not frame rate this mode needs. + // + // Only supplies a sector count where fewer than four were asked for; an explicit count stands. + void ApplyToAzimuthalIntegration(AzimuthalIntegrationSettings &azint) const; +}; diff --git a/common/DiffractionExperiment.cpp b/common/DiffractionExperiment.cpp index c8b6be6de..7e740b8a6 100644 --- a/common/DiffractionExperiment.cpp +++ b/common/DiffractionExperiment.cpp @@ -1776,20 +1776,11 @@ BraggIntegrationSettings DiffractionExperiment::GetBraggIntegrationSettings() co DiffractionExperiment &DiffractionExperiment::ImportAnalysisSettings(const AnalysisSettings &input) { analysis = input; - // Powder calibration reads ring positions off a (q x azimuth) profile, so it needs azimuthal - // sectors - and enough q bins under each of them to place a ring. The FPGA integration core holds - // FPGA_INTEGRATION_BIN_COUNT = 2048 bins in total, which at CALIBRATION_AZIM_BINS_DEFAULT sectors - // would leave 64 q bins, so the integration is moved onto the CPU where the limit does not apply. - // The cost is frame rate, which this mode does not need: a calibration exposure is a few images at - // a few Hz. Setting the mode is therefore all a caller has to do - see AnalysisSettings.h. - if (analysis.GetMode() == AnalysisMode::PowderCalibration) { - az_integration_settings.ForceCPUinFPGAWorkflow(true); - // Only where no usable sector count was asked for. Below four sectors a ring has been averaged - // over every direction and nothing is left to say where its centre is (RingsFromAzimuthalProfile - // refuses there), and the default of one is exactly that; an explicit count stands. - if (az_integration_settings.GetAzimuthalBinCount() < 4) - az_integration_settings.AzimuthalBinCount(CALIBRATION_AZIM_BINS_DEFAULT); - } + // Powder calibration cannot be integrated on the FPGA; the rule and the reason live with the + // calibration settings. Applied from both imports, so it does not matter which of the mode and + // the calibration settings is set first. + if (analysis.GetMode() == AnalysisMode::PowderCalibration) + calibration.ApplyToAzimuthalIntegration(az_integration_settings); return *this; } @@ -1798,12 +1789,40 @@ AnalysisSettings DiffractionExperiment::GetAnalysisSettings() const { return analysis; } +DiffractionExperiment & +DiffractionExperiment::ImportGridScanAnalysisSettings(const GridScanAnalysisSettings &input) { + grid_scan_analysis = input; + return *this; +} + +GridScanAnalysisSettings DiffractionExperiment::GetGridScanAnalysisSettings() const { + return grid_scan_analysis; +} + +DiffractionExperiment &DiffractionExperiment::ImportCalibrationSettings(const CalibrationSettings &input) { + calibration = input; + + if (analysis.GetMode() == AnalysisMode::PowderCalibration) + calibration.ApplyToAzimuthalIntegration(az_integration_settings); + + return *this; +} + +CalibrationSettings DiffractionExperiment::GetCalibrationSettings() const { + return calibration; +} + AnalysisMode DiffractionExperiment::GetAnalysisMode() const { return analysis.GetMode(); } AnalysisStages DiffractionExperiment::GetAnalysisStages() const { - return AnalysisModeStages(analysis.GetMode()); + auto stages = AnalysisModeStages(analysis.GetMode()); + // Every stage is the mode's to decide except one: whether a raster indexes is a setting, and this + // is where the configured value replaces the default the table carries. + if (analysis.GetMode() == AnalysisMode::Grid) + stages.indexing = grid_scan_analysis.IsIndexing(); + return stages; } DiffractionExperiment &DiffractionExperiment::ImportScalingSettings(const ScalingSettings &input) { diff --git a/common/DiffractionExperiment.h b/common/DiffractionExperiment.h index 51e48a709..3a9757b8d 100644 --- a/common/DiffractionExperiment.h +++ b/common/DiffractionExperiment.h @@ -38,6 +38,8 @@ constexpr int64_t SaturationLimitFromValue(int64_t saturation_value) { return sa #include "IndexingSettings.h" #include "BraggIntegrationSettings.h" #include "AnalysisSettings.h" +#include "GridScanAnalysisSettings.h" +#include "CalibrationSettings.h" #include "ScalingSettings.h" #include @@ -87,7 +89,10 @@ class DiffractionExperiment { BraggIntegrationSettings bragg_integration_settings; // Persistent, deliberately outside the DatasetSettings member above: a /start replaces `dataset` // wholesale, and the analysis mode is a property of how the instrument is set up, not of one run. + // One shared group plus one per analysis method, bound the same way. AnalysisSettings analysis; + GridScanAnalysisSettings grid_scan_analysis; + CalibrationSettings calibration; ScalingSettings scaling_settings; DarkMaskSettings dark_mask_settings; @@ -203,9 +208,16 @@ public: DiffractionExperiment& ImportBraggIntegrationSettings(const BraggIntegrationSettings& input); BraggIntegrationSettings GetBraggIntegrationSettings() const; - // PowderCalibration also rewrites the azimuthal-integration settings it needs - see the definition. DiffractionExperiment& ImportAnalysisSettings(const AnalysisSettings& input); AnalysisSettings GetAnalysisSettings() const; + + DiffractionExperiment& ImportGridScanAnalysisSettings(const GridScanAnalysisSettings& input); + GridScanAnalysisSettings GetGridScanAnalysisSettings() const; + + // Under AnalysisMode::PowderCalibration this also rewrites the azimuthal integration the fit + // needs - see CalibrationSettings::ApplyToAzimuthalIntegration for what and why. + DiffractionExperiment& ImportCalibrationSettings(const CalibrationSettings& input); + CalibrationSettings GetCalibrationSettings() const; [[nodiscard]] AnalysisMode GetAnalysisMode() const; // What this experiment's mode runs. Every gate in the pipeline reads this; the table is in // common/AnalysisSettings.cpp. diff --git a/common/GridScanAnalysisSettings.cpp b/common/GridScanAnalysisSettings.cpp new file mode 100644 index 000000000..b90f98b33 --- /dev/null +++ b/common/GridScanAnalysisSettings.cpp @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include "GridScanAnalysisSettings.h" + +GridScanAnalysisSettings &GridScanAnalysisSettings::ProteinScoreThreshold(float input) { + protein_score_threshold = input; + return *this; +} + +GridScanAnalysisSettings &GridScanAnalysisSettings::MinBlobCells(int64_t input) { + min_blob_cells = input; + return *this; +} + +GridScanAnalysisSettings &GridScanAnalysisSettings::DecisiveSingleCellScore(float input) { + decisive_single_cell_score = input; + return *this; +} + +GridScanAnalysisSettings &GridScanAnalysisSettings::MaxCrystals(int64_t input) { + max_crystals = input; + return *this; +} + +GridScanAnalysisSettings &GridScanAnalysisSettings::Indexing(bool input) { + indexing = input; + return *this; +} + +float GridScanAnalysisSettings::GetProteinScoreThreshold() const { + return protein_score_threshold; +} + +int64_t GridScanAnalysisSettings::GetMinBlobCells() const { + return min_blob_cells; +} + +float GridScanAnalysisSettings::GetDecisiveSingleCellScore() const { + return decisive_single_cell_score; +} + +int64_t GridScanAnalysisSettings::GetMaxCrystals() const { + return max_crystals; +} + +bool GridScanAnalysisSettings::IsIndexing() const { + return indexing; +} diff --git a/common/GridScanAnalysisSettings.h b/common/GridScanAnalysisSettings.h new file mode 100644 index 000000000..3221e8e31 --- /dev/null +++ b/common/GridScanAnalysisSettings.h @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include + +// Settings for AnalysisMode::Grid and nothing else. Per-method by the same rule as +// CalibrationSettings: AnalysisSettings carries what every method shares, each method carries its +// own knobs. +// +// THIS IS THE SINGLE HOME FOR THESE VALUES. AnalyzeGridScan +// (image_analysis/grid_scan_analysis/AnalyzeGridScan.h) is to take this object as one argument - +// +// GridScanResult AnalyzeGridScan(const ScanResult &scan, const GridScanSettings &grid, +// float beam_size_x_um, float beam_size_y_um, +// const GridScanAnalysisSettings &settings = {}); +// +// - rather than loose parameters carrying defaults of their own, so that a knob has one default and +// one place where it is documented. PROTEIN_SCORE_THRESHOLD_DEFAULT and MIN_BLOB_CELLS_DEFAULT are +// replaced by the two members below and must not be reintroduced beside that header. +// +// The beam size stays a separate argument on purpose: it is measured (from the file, or --beam-size), +// not configured, and rugnux reports where it came from beside the crystals it sized. +class GridScanAnalysisSettings { + // A cell counts as protein above this. The per-image protein score saturates, so this only has + // to separate "something diffracted here" from "nothing did". + float protein_score_threshold = 0.5f; + // How many cells above that threshold make a shape rather than a coincidence. Two cells can be + // the two ends of a single hit lying on a cell boundary; three is the smallest patch that is not. + int64_t min_blob_cells = 3; + // ...unless one cell on its own is decisive. The rule above is about coincidences, and a lone + // cell scoring near the top of a saturating score is not one - a crystal smaller than the grid + // step lights exactly one cell, and refusing it would lose precisely the samples a fine raster is + // run to find. Well above protein_score_threshold on purpose: this admits the obvious case, it + // does not lower the general threshold by the back door. + float decisive_single_cell_score = 0.9f; + // Most crystals reported. A raster over a loop full of shards can label dozens of blobs, and past + // the first few the list is no longer a ranking anyone acts on - the DAQ collects from the top of + // it. Crystals are sorted by score, so this keeps the best. + int64_t max_crystals = 10; + // Whether each raster cell is indexed as well as scored. + // + // On by default. It is affordable - a raster runs at up to 100 Hz, which the FFT indexer keeps up + // with - and it is additive: the blobs are still found on the protein score, so indexing changes + // nothing about which cells are called crystals and only adds what was found in them. The lattice + // count per cell is the cheapest multi-lattice or cracked-crystal signal there is, and on a + // fixed-target serial experiment with a known cell (-C, ffbidx) a raster that indexes is most of + // the measurement. Turn it off for a very large raster where the GPU is the constraint. + bool indexing = true; +public: + GridScanAnalysisSettings& ProteinScoreThreshold(float input); + GridScanAnalysisSettings& MinBlobCells(int64_t input); + GridScanAnalysisSettings& DecisiveSingleCellScore(float input); + GridScanAnalysisSettings& MaxCrystals(int64_t input); + GridScanAnalysisSettings& Indexing(bool input); + + [[nodiscard]] float GetProteinScoreThreshold() const; + [[nodiscard]] int64_t GetMinBlobCells() const; + [[nodiscard]] float GetDecisiveSingleCellScore() const; + [[nodiscard]] int64_t GetMaxCrystals() const; + [[nodiscard]] bool IsIndexing() const; +}; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e561147f1..b43f68b03 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,8 @@ * jfjoch_broker takes an explicit analysis mode (`/config/analysis`, the `analysis` block of the configuration file, and the Analysis panel of the web frontend): `MXStills` (the default), `Azint`, `Grid`, `PowderCalibration` or `None`. The mode decides what runs and takes precedence over `dataset_settings.spot_finding` and `spot_finding_settings.enable`, which are now deprecated. * `PowderCalibration` moves azimuthal integration onto the CPU and sets a sector count of its own, since the FPGA integration core cannot hold enough bins for a sectored profile; a calibration exposure then runs at a few Hz. +* Each analysis method has its own settings and its own endpoint: `/config/grid_scan_analysis` (protein-score threshold, minimum cells per crystal, decisive single-cell score, maximum crystals reported, and whether the raster indexes) and `/config/calibration` (calibrant and ring source). `/config/analysis` now carries the mode alone. +* A grid scan indexes each raster cell by default; turn it off with `indexing` in `/config/grid_scan_analysis`. * Rotation MX analysis is not offered online: it is absent from the REST API and refused by the broker. Rotation data is collected with jfjoch_broker and processed with rugnux. * `rugnux --mode` accepts `mx_rotation` and `mx_stills` beside `mx`, which continues to choose between them from the goniometer. * The stream and the written file record which analysis produced them, as `analysis_mode` in the CBOR start message and `/entry/MX/analysis_mode` in the HDF5 master. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0869ec6b8..b350d69f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -44,6 +44,8 @@ import DarkMaskSettings from "./components/DarkMaskSettings"; import IndexingSettings from "./components/IndexingSettings"; import BraggIntegrationSettings from "./components/BraggIntegrationSettings"; import AnalysisSettings from "./components/AnalysisSettings"; +import GridScanAnalysisSettings from "./components/GridScanAnalysisSettings"; +import CalibrationSettings from "./components/CalibrationSettings"; import AzIntSettings from "./components/AzIntSettings"; import ROI from "./components/ROI"; import FileWriterSettings from "./components/FileWriterSettings"; @@ -216,6 +218,8 @@ function App() { + + {/* ROI holds three wide side-by-side tables — keep it full width. */} diff --git a/frontend/src/components/AnalysisSettings.tsx b/frontend/src/components/AnalysisSettings.tsx index e1dc45c18..27a488ddb 100644 --- a/frontend/src/components/AnalysisSettings.tsx +++ b/frontend/src/components/AnalysisSettings.tsx @@ -4,7 +4,6 @@ import FormControl from "@mui/material/FormControl"; import InputLabel from "@mui/material/InputLabel"; import Select, {SelectChangeEvent} from "@mui/material/Select"; import MenuItem from "@mui/material/MenuItem"; -import TextField from "@mui/material/TextField"; import {analysis_mode, analysis_settings} from "../client"; import {putConfigAnalysisMutation} from "../client/@tanstack/react-query.gen"; import _ from "lodash"; @@ -62,23 +61,9 @@ function AnalysisSettings({s: serverS}: MyProps) { The mode decides what runs, and takes precedence over the spot-finding switches. Rotation analysis is not available online - collect the sweep here and process the stored file with - rugnux. + rugnux. Grid scan and powder calibration have settings of their own, in the panels beside + this one. - {s.mode === analysis_mode.POWDER_CALIBRATION && ( - <> - Calibrant - - setS(prev => ({...prev, calibrant: e.target.value}))} - fullWidth/> - - Which standard's ring d-spacings the geometry is fitted against. Powder calibration - also moves azimuthal integration onto the CPU - the FPGA core cannot hold enough bins - for a sectored profile - so it runs at a few Hz, which is all a calibration exposure - needs. - - )} ); } diff --git a/frontend/src/components/CalibrationSettings.tsx b/frontend/src/components/CalibrationSettings.tsx new file mode 100644 index 000000000..375e3a794 --- /dev/null +++ b/frontend/src/components/CalibrationSettings.tsx @@ -0,0 +1,69 @@ +import {memo, useEffect, useState} from 'react'; + +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import Select, {SelectChangeEvent} from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import {calibration_method, calibration_settings} from "../client"; +import {putConfigCalibrationMutation} from "../client/@tanstack/react-query.gen"; +import _ from "lodash"; +import SettingsPanel from "./SettingsPanel"; +import {useUpload} from "./useUpload"; + +type MyProps = { + s?: calibration_settings +} + +const default_calibration_settings: calibration_settings = { + method: calibration_method.RINGS +}; + +function CalibrationSettings({s: serverS}: MyProps) { + const [s, setS] = useState(default_calibration_settings); + const [lastDownloadedS, setLastDownloadedS] = useState(default_calibration_settings); + const {submit, pending, snackbar} = useUpload(putConfigCalibrationMutation()); + + useEffect(() => { + if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) { + setS(serverS); + setLastDownloadedS(serverS); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [serverS]); + + const handleMethodChange = (event: SelectChangeEvent) => { + setS(prev => ({...prev, method: event.target.value as calibration_method})); + }; + + const dirty = !_.isEqual(s, lastDownloadedS); + + return ( + submit(s)} uploadDisabled={pending} snackbar={snackbar}> + Calibrant + + setS(prev => ({...prev, calibrant: e.target.value}))} + fullWidth/> + + Which standard's ring d-spacings the geometry is fitted against. Empty selects the + processing program's own default rather than meaning "no calibrant". + + Ring source + + Ring source + + + Selecting the powder calibration analysis mode also moves azimuthal integration onto the + CPU and gives it enough azimuthal sectors to locate a ring, so it runs at a few Hz — which + is all a calibration exposure needs. + + ); +} + +export default memo(CalibrationSettings); diff --git a/frontend/src/components/GridScanAnalysisSettings.tsx b/frontend/src/components/GridScanAnalysisSettings.tsx new file mode 100644 index 000000000..ebc658dbd --- /dev/null +++ b/frontend/src/components/GridScanAnalysisSettings.tsx @@ -0,0 +1,133 @@ +import {memo, useEffect, useState} from 'react'; + +import {Checkbox, FormControl, FormControlLabel} from '@mui/material'; +import {grid_scan_analysis_settings} from "../client"; +import {putConfigGridScanAnalysisMutation} from "../client/@tanstack/react-query.gen"; +import _ from "lodash"; +import NumberTextField from "./NumberTextField"; +import SettingsPanel from "./SettingsPanel"; +import {useUpload} from "./useUpload"; + +type MyProps = { + s?: grid_scan_analysis_settings +} + +const default_grid_scan_analysis_settings: grid_scan_analysis_settings = { + protein_score_threshold: 0.5, + min_blob_cells: 3, + decisive_single_cell_score: 0.9, + max_crystals: 10, + indexing: true +}; + +function GridScanAnalysisSettings({s: serverS}: MyProps) { + const [s, setS] = useState(default_grid_scan_analysis_settings); + const [lastDownloadedS, setLastDownloadedS] = + useState(default_grid_scan_analysis_settings); + const [downloadCounter, setDownloadCounter] = useState(0); + const [thresholdErr, setThresholdErr] = useState(false); + const [minCellsErr, setMinCellsErr] = useState(false); + const [decisiveErr, setDecisiveErr] = useState(false); + const [maxCrystalsErr, setMaxCrystalsErr] = useState(false); + const {submit, pending, snackbar} = useUpload(putConfigGridScanAnalysisMutation()); + + useEffect(() => { + if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) { + setS(serverS); + setLastDownloadedS(serverS); + setDownloadCounter(c => c + 1); + setThresholdErr(false); + setMinCellsErr(false); + setDecisiveErr(false); + setMaxCrystalsErr(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [serverS]); + + const hasError = () => thresholdErr || minCellsErr || decisiveErr || maxCrystalsErr; + const dirty = !_.isEqual(s, lastDownloadedS); + + return ( + submit(s)} uploadDisabled={pending || hasError()} snackbar={snackbar}> + Which cells are a crystal + + { + setS(prev => ({...prev, protein_score_threshold: val})); + setThresholdErr(err); + }} + float decimals={2} + fullWidth/> + + The per-image protein score saturates, so this only has to separate "something diffracted + here" from "nothing did". + + + { + setS(prev => ({...prev, min_blob_cells: val})); + setMinCellsErr(err); + }} + fullWidth/> + + Two cells can be the two ends of a single hit on a cell boundary; three is the smallest + patch that is a shape rather than a coincidence. + + + { + setS(prev => ({...prev, decisive_single_cell_score: val})); + setDecisiveErr(err); + }} + float decimals={2} + fullWidth/> + + One cell scoring this high is a crystal on its own — a crystal smaller than the grid step + lights exactly one cell. Keep it well above the threshold above. + + Reporting + + { + setS(prev => ({...prev, max_crystals: val})); + setMaxCrystalsErr(err); + }} + fullWidth/> + + Crystals are sorted by score, so this keeps the best. + + Indexing + setS(prev => ({...prev, indexing: e.target.checked}))}/>} + label="Index each raster cell"/> + Additive: crystals are still found on the protein score, so indexing only adds what was + found in them. Turn it off for a very large raster where the GPU is the constraint. + + ); +} + +export default memo(GridScanAnalysisSettings); diff --git a/image_analysis/geom_refinement/PowderCalibration.h b/image_analysis/geom_refinement/PowderCalibration.h index 6e00452d5..5f34b5a9f 100644 --- a/image_analysis/geom_refinement/PowderCalibration.h +++ b/image_analysis/geom_refinement/PowderCalibration.h @@ -8,18 +8,13 @@ #include #include "../../common/AzimuthalIntegrationMapping.h" +#include "../../common/CalibrationSettings.h" // CalibrationMethod #include "../../common/DiffractionExperiment.h" #include "../../common/DiffractionGeometry.h" #include "../../common/SpotToSave.h" #include "PowderAutoSeed.h" #include "RingOptimizer.h" // RingFitUncertainty -// How the powder rings the detector geometry is fitted to are measured (rugnux --calibration). -enum class CalibrationMethod { - Rings, // the run-summed (q x azimuth) azimuthal profile: the ring measured at every azimuth - Spots // the pooled per-image spot lists: the ring sampled wherever the spot finder bit -}; - struct CalibrationResult { DiffractionGeometry geometry; // the fitted geometry size_t ring_points = 0; // ring measurements the fit used diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 5101fe6da..9f74b6166 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -2075,6 +2075,11 @@ static int RunRugnux(int argc, char **argv) { ApplyRugnuxExperimentDefaults(experiment); if (polarization_factor) experiment.PolarizationFactor(polarization_factor.value()); + // The calibrant and the ring source on the experiment as well as on the config: one home for + // them, and what the written file records the calibration as having used. + experiment.ImportCalibrationSettings( + CalibrationSettings().Calibrant(calibrant).Method(calibration_method)); + ProcessConfig config; config.mode = AnalysisMode::PowderCalibration; config.calibration_method = calibration_method; diff --git a/tests/AnalysisSettingsTest.cpp b/tests/AnalysisSettingsTest.cpp index 0142b1095..4595e45d1 100644 --- a/tests/AnalysisSettingsTest.cpp +++ b/tests/AnalysisSettingsTest.cpp @@ -55,10 +55,48 @@ TEST_CASE("AnalysisMode_Stages", "[AnalysisMode]") { CHECK(calib.spot_finding); CHECK_FALSE(calib.indexing); + // Grid indexing is a setting, so the table only carries its default. const auto grid = AnalysisModeStages(AnalysisMode::Grid); CHECK(grid.spot_finding); CHECK(grid.scoring); - CHECK_FALSE(grid.indexing); + CHECK(grid.indexing == GridScanAnalysisSettings().IsIndexing()); +} + +TEST_CASE("AnalysisMode_GridIndexingIsASetting", "[AnalysisMode]") { + // The one stage the mode does not fix. The configured value must reach the gates, which read + // GetAnalysisStages and not the table. + DiffractionExperiment experiment; + experiment.ImportAnalysisSettings(AnalysisSettings().Mode(AnalysisMode::Grid)); + CHECK(experiment.GetAnalysisStages().indexing); // the default is on + + experiment.ImportGridScanAnalysisSettings(GridScanAnalysisSettings().Indexing(false)); + CHECK_FALSE(experiment.GetAnalysisStages().indexing); + + experiment.ImportGridScanAnalysisSettings(GridScanAnalysisSettings().Indexing(true)); + CHECK(experiment.GetAnalysisStages().indexing); + + // ...and only under Grid. No other mode's indexing answer moves with it. + experiment.ImportAnalysisSettings(AnalysisSettings().Mode(AnalysisMode::Azint)); + experiment.ImportGridScanAnalysisSettings(GridScanAnalysisSettings().Indexing(true)); + CHECK_FALSE(experiment.GetAnalysisStages().indexing); +} + +TEST_CASE("AnalysisMode_GridScanSettingsDefaults", "[AnalysisMode]") { + const GridScanAnalysisSettings s; + // A single cell is admitted only well above the threshold that admits a cell into a blob; + // otherwise the decisive rule would just be a lower general threshold. + CHECK(s.GetDecisiveSingleCellScore() > s.GetProteinScoreThreshold()); + CHECK(s.GetMinBlobCells() >= 1); + CHECK(s.GetMaxCrystals() >= 1); + + GridScanAnalysisSettings set; + set.ProteinScoreThreshold(0.7f).MinBlobCells(5).DecisiveSingleCellScore(0.95f) + .MaxCrystals(3).Indexing(false); + CHECK(set.GetProteinScoreThreshold() == Catch::Approx(0.7)); + CHECK(set.GetMinBlobCells() == 5); + CHECK(set.GetDecisiveSingleCellScore() == Catch::Approx(0.95)); + CHECK(set.GetMaxCrystals() == 3); + CHECK_FALSE(set.IsIndexing()); } TEST_CASE("AnalysisMode_PrecedenceOverSpotFindingSwitch", "[AnalysisMode]") { @@ -99,6 +137,25 @@ TEST_CASE("AnalysisMode_PowderCalibrationForcesCPUAzInt", "[AnalysisMode]") { AzimuthalIntegrationSettings().AzimuthalBinCount(64)); explicit_bins.ImportAnalysisSettings(AnalysisSettings().Mode(AnalysisMode::PowderCalibration)); CHECK(explicit_bins.GetAzimuthalIntegrationSettings().GetAzimuthalBinCount() == 64); + + // The coupling is applied from both imports, so the order the two are set in does not matter. + DiffractionExperiment settings_first; + settings_first.ImportCalibrationSettings(CalibrationSettings().Calibrant("lab6")); + CHECK_FALSE(settings_first.GetAzimuthalIntegrationSettings().IsForceCPUinFPGAWorkflow()); + settings_first.ImportAnalysisSettings(AnalysisSettings().Mode(AnalysisMode::PowderCalibration)); + CHECK(settings_first.GetAzimuthalIntegrationSettings().IsForceCPUinFPGAWorkflow()); + + DiffractionExperiment mode_first; + mode_first.ImportAnalysisSettings(AnalysisSettings().Mode(AnalysisMode::PowderCalibration)); + mode_first.ImportCalibrationSettings(CalibrationSettings().Calibrant("lab6")); + CHECK(mode_first.GetAzimuthalIntegrationSettings().IsForceCPUinFPGAWorkflow()); + CHECK(mode_first.GetCalibrationSettings().GetCalibrant() == "lab6"); + + // A mode that is not a calibration is left alone. + DiffractionExperiment stills; + stills.ImportCalibrationSettings(CalibrationSettings().Method(CalibrationMethod::Spots)); + CHECK_FALSE(stills.GetAzimuthalIntegrationSettings().IsForceCPUinFPGAWorkflow()); + CHECK(stills.GetCalibrationSettings().GetMethod() == CalibrationMethod::Spots); } TEST_CASE("AnalysisMode_CBORStartRoundTrip", "[AnalysisMode][CBOR]") {