Files
Jungfraujoch/tests/AnalysisSettingsTest.cpp
T
leonarski_fandClaude Opus 5 d4f280047f grid scan: a raster reaches the spot engine, and the crystal cap says none rather than zero
Two defects the merge created and one the API carried.

Rugnux gated the per-image spot engine on AnalysisModeIsMX, so AnalysisMode::Grid
fell through to the azimuthal-integration-only path: a raster ran, scored nothing,
and reported no crystals. The gate now asks the stages table whether the mode does
spot finding, which is the actual question - three modes need that engine for three
different reasons, and a fourth would otherwise have to be remembered here too.

max_crystals was a required integer defaulting to 10, with 0 meaning "all". Zero
reads as "report no crystals", the opposite of what it did. It is now optional, and
absent means no cap; a crystal found and then dropped is information the caller
cannot get back. grow_score_threshold was missing from the schema entirely.

Measured over the labelled corpus after these fixes: 34 of 34 confirmed-protein
rasters yield a crystal, 0 of 8 water, 0 of 6 ice, 19 of 19 heldout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
2026-09-08 08:22:14 +02:00

282 lines
14 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_all.hpp>
#include "../common/AnalysisSettings.h"
#include "../common/DiffractionExperiment.h"
#include "../frame_serialize/CBORStream2Serializer.h"
#include "../frame_serialize/CBORStream2Deserializer.h"
#include "../writer/FileWriter.h"
#include "../reader/JFJochHDF5Reader.h"
TEST_CASE("AnalysisMode_Default", "[AnalysisMode]") {
// MXStills, not None: a deployment whose configuration predates the mode must keep analysing.
CHECK(AnalysisSettings().GetMode() == AnalysisMode::MXStills);
CHECK(DiffractionExperiment().GetAnalysisMode() == AnalysisMode::MXStills);
}
TEST_CASE("AnalysisMode_Names", "[AnalysisMode]") {
for (auto mode : {AnalysisMode::None, AnalysisMode::MXRotation, AnalysisMode::MXStills,
AnalysisMode::Azint, AnalysisMode::Grid, AnalysisMode::PowderCalibration}) {
auto back = AnalysisModeFromName(AnalysisModeName(mode));
REQUIRE(back.has_value());
CHECK(*back == mode);
}
CHECK_FALSE(AnalysisModeFromName("").has_value());
CHECK_FALSE(AnalysisModeFromName("mx").has_value());
}
TEST_CASE("AnalysisMode_Stages", "[AnalysisMode]") {
// The table itself. Bragg integration must never be on where indexing is off - nothing is
// predicted without a lattice.
for (auto mode : {AnalysisMode::None, AnalysisMode::MXRotation, AnalysisMode::MXStills,
AnalysisMode::Azint, AnalysisMode::Grid, AnalysisMode::PowderCalibration}) {
const auto s = AnalysisModeStages(mode);
CHECK((!s.bragg_integration || s.indexing));
CHECK((!s.scale_merge || s.bragg_integration));
}
CHECK(AnalysisModeStages(AnalysisMode::MXStills).indexing);
CHECK(AnalysisModeStages(AnalysisMode::MXRotation).indexing);
const auto none = AnalysisModeStages(AnalysisMode::None);
CHECK_FALSE(none.spot_finding);
CHECK_FALSE(none.azimuthal_integration);
const auto azint = AnalysisModeStages(AnalysisMode::Azint);
CHECK_FALSE(azint.spot_finding);
CHECK_FALSE(azint.indexing);
CHECK_FALSE(azint.scoring);
CHECK(azint.azimuthal_integration);
// Calibration keeps the spot finder: --calibration spots fits the pooled spots.
const auto calib = AnalysisModeStages(AnalysisMode::PowderCalibration);
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(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);
// No cap by default: a crystal found and then dropped cannot be recovered by the caller.
CHECK_FALSE(s.GetMaxCrystals().has_value());
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]") {
DiffractionExperiment experiment;
DatasetSettings dataset;
dataset.SpotFindingEnable(true).MaxSpotCount(500);
experiment.ImportDatasetSettings(dataset);
CHECK(experiment.IsSpotFindingEnabled());
// A mode that analyses no spots wins over the deprecated per-dataset switch, and takes the spot
// budget with it.
experiment.ImportAnalysisSettings(AnalysisSettings().Mode(AnalysisMode::Azint));
CHECK_FALSE(experiment.IsSpotFindingEnabled());
CHECK(experiment.GetMaxSpotCount() == 0);
// Under a mode that does find spots, the deprecated switch is still able to turn it off.
experiment.ImportAnalysisSettings(AnalysisSettings().Mode(AnalysisMode::MXStills));
CHECK(experiment.IsSpotFindingEnabled());
dataset.SpotFindingEnable(false);
experiment.ImportDatasetSettings(dataset);
CHECK_FALSE(experiment.IsSpotFindingEnabled());
}
TEST_CASE("AnalysisMode_PowderCalibrationForcesCPUAzInt", "[AnalysisMode]") {
// The FPGA integration core holds 2048 bins in total, so a sectored profile cannot be built there.
DiffractionExperiment experiment;
REQUIRE_FALSE(experiment.GetAzimuthalIntegrationSettings().IsForceCPUinFPGAWorkflow());
experiment.ImportAnalysisSettings(AnalysisSettings().Mode(AnalysisMode::PowderCalibration));
CHECK(experiment.GetAzimuthalIntegrationSettings().IsForceCPUinFPGAWorkflow());
CHECK(experiment.GetAzimuthalIntegrationSettings().GetAzimuthalBinCount()
== CALIBRATION_AZIM_BINS_DEFAULT);
// An explicit sector count stands; the mode only supplies one where none is usable.
DiffractionExperiment explicit_bins;
explicit_bins.ImportAzimuthalIntegrationSettings(
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]") {
std::vector<uint8_t> buffer(1024 * 1024);
CBORStream2Serializer serializer(buffer.data(), buffer.size());
DiffractionExperiment experiment;
experiment.ImportAnalysisSettings(AnalysisSettings().Mode(AnalysisMode::Grid));
StartMessage message{};
experiment.FillMessage(message);
REQUIRE(message.analysis_mode.has_value());
REQUIRE_NOTHROW(serializer.SerializeSequenceStart(message));
auto deserialized = CBORStream2Deserialize(buffer.data(), serializer.GetBufferSize());
REQUIRE(deserialized);
REQUIRE(deserialized->start_message);
REQUIRE(deserialized->start_message->analysis_mode.has_value());
CHECK(*deserialized->start_message->analysis_mode == AnalysisMode::Grid);
}
TEST_CASE("AnalysisMode_CBORStartAbsentMode", "[AnalysisMode][CBOR]") {
// A stream written before the mode existed says nothing, and is read back as saying nothing -
// naming a mode for it would be an invention rather than provenance.
std::vector<uint8_t> buffer(1024 * 1024);
CBORStream2Serializer serializer(buffer.data(), buffer.size());
StartMessage message{};
REQUIRE_NOTHROW(serializer.SerializeSequenceStart(message));
auto deserialized = CBORStream2Deserialize(buffer.data(), serializer.GetBufferSize());
REQUIRE(deserialized);
REQUIRE(deserialized->start_message);
CHECK_FALSE(deserialized->start_message->analysis_mode.has_value());
}
TEST_CASE("AnalysisMode_CBORGridCrystals", "[AnalysisMode][CBOR]") {
std::vector<uint8_t> buffer(1024 * 1024);
CBORStream2Serializer serializer(buffer.data(), buffer.size());
EndMessage message{};
message.max_image_number = 0;
// Two crystals, not one: a raster can find any number and nothing may assume at most one.
message.grid_crystals.push_back(GridScanCrystal{
.nx = 3.5f, .ny = 7.25f, .x_um = -12.5f, .y_um = 40.0f, .image_number = 143,
.major_um = 22.5f, .minor_um = 8.0f, .angle_deg = 179.5f, .score = 0.92f,
.ice_score = 0.03f, .res_A = 1.85f, .n_images = 17});
message.grid_crystals.push_back(GridScanCrystal{
.nx = 11.0f, .ny = 2.0f, .x_um = 60.0f, .y_um = -30.0f, .image_number = 44,
.major_um = 9.0f, .minor_um = 7.5f, .angle_deg = 0.5f, .score = 0.41f,
.ice_score = 0.30f, .res_A = 3.2f, .n_images = 4});
REQUIRE_NOTHROW(serializer.SerializeSequenceEnd(message));
auto deserialized = CBORStream2Deserialize(buffer.data(), serializer.GetBufferSize());
REQUIRE(deserialized);
REQUIRE(deserialized->end_message);
const auto &out = deserialized->end_message->grid_crystals;
REQUIRE(out.size() == 2);
CHECK(out[0].nx == Catch::Approx(3.5));
CHECK(out[0].image_number == 143);
CHECK(out[0].major_um == Catch::Approx(22.5));
CHECK(out[0].angle_deg == Catch::Approx(179.5));
CHECK(out[0].res_A == Catch::Approx(1.85));
CHECK(out[0].n_images == 17);
CHECK(out[1].score == Catch::Approx(0.41));
CHECK(out[1].ice_score == Catch::Approx(0.30));
CHECK(out[1].y_um == Catch::Approx(-30.0));
}
TEST_CASE("AnalysisMode_HDF5MasterRoundTrip", "[AnalysisMode][HDF5][Full]") {
// The mode and the crystal list are dataset-wide metadata: written to the master, read back from
// it, so a stored raster re-opens knowing what produced it and what it found.
DiffractionExperiment x(DetJF(1));
x.FilePrefix("test_analysis_mode").ImagesPerTrigger(1).OverwriteExistingFiles(true);
x.BeamX_pxl(100).BeamY_pxl(200).DetectorDistance_mm(150)
.IncidentEnergy_keV(WVL_1A_IN_KEV).PixelSigned(false).BitDepthImage(16)
.FrameTime(std::chrono::microseconds(500), std::chrono::microseconds(10));
x.ImportAnalysisSettings(AnalysisSettings().Mode(AnalysisMode::Grid));
RegisterHDF5Filter();
std::vector<uint16_t> image(x.GetPixelsNum(), 0);
{
StartMessage start_message;
x.FillMessage(start_message);
FileWriter file_set(start_message);
DataMessage message{};
message.image = CompressedImage(image, x.GetXPixelsNum(), x.GetYPixelsNum());
message.number = 0;
REQUIRE_NOTHROW(file_set.WriteHDF5(message));
EndMessage end_message;
end_message.max_image_number = 1;
end_message.grid_crystals.push_back(GridScanCrystal{
.nx = 3.5f, .ny = 7.25f, .x_um = -12.5f, .y_um = 40.0f, .image_number = 143,
.major_um = 22.5f, .minor_um = 8.0f, .angle_deg = 179.5f, .score = 0.92f,
.ice_score = 0.03f, .res_A = 1.85f, .n_images = 17});
end_message.grid_crystals.push_back(GridScanCrystal{
.nx = 11.0f, .ny = 2.0f, .x_um = 60.0f, .y_um = -30.0f, .image_number = 44,
.major_um = 9.0f, .minor_um = 7.5f, .angle_deg = 0.5f, .score = 0.41f,
.ice_score = 0.30f, .res_A = 3.2f, .n_images = 4});
REQUIRE_NOTHROW(file_set.WriteHDF5(end_message));
file_set.Finalize();
}
{
JFJochHDF5Reader reader;
reader.ReadFile("test_analysis_mode_master.h5");
auto dataset = reader.GetDataset();
REQUIRE(dataset->file_analysis_mode.has_value());
CHECK(*dataset->file_analysis_mode == AnalysisMode::Grid);
REQUIRE(dataset->grid_crystals.size() == 2);
CHECK(dataset->grid_crystals[0].nx == Catch::Approx(3.5));
CHECK(dataset->grid_crystals[0].image_number == 143);
CHECK(dataset->grid_crystals[0].angle_deg == Catch::Approx(179.5));
CHECK(dataset->grid_crystals[0].n_images == 17);
CHECK(dataset->grid_crystals[1].score == Catch::Approx(0.41));
CHECK(dataset->grid_crystals[1].res_A == Catch::Approx(3.2));
}
remove("test_analysis_mode_master.h5");
remove("test_analysis_mode_data_000001.h5");
}