Files
Jungfraujoch/tests/AnalysisSettingsTest.cpp
T
leonarski_fandClaude Opus 5 550f7c7d7b scan result: a grid scan's crystals and a calibration's geometry reach the stream, the file and the API
A grid scan and a powder calibration each produce an answer that is not a per-image number,
and neither had anywhere to go. ScanResult gains two top-level optionals, grid and
calibration, in the same idiom the three rotation_* members already use: a mode's payload is
present when that mode ran and absent otherwise. images stays required, so every existing
/result/scan client - the python client and the beamline scripts on it - is untouched.

No oneOf or discriminator. The spec contains none today, so polymorphism is unexercised across
all three generators at once, and the only thing it would buy is making "exactly one payload"
structurally true rather than true by convention. powder_calibration_output has been fully
specified and referenced by nothing since it was written; the calibration member reuses it.

The crystal list travels the same three hops a per-image quantity does. In the CBOR end
message grid_crystals is an array of maps keyed by field name - the shape a spot already uses
- so a reader that does not know a field skips it and the record survives a field being added;
the deserializer steps over an unknown key rather than reading its payload as the next one. In
the HDF5 master it is parallel 1-D datasets under /entry/MX/crystals, one per field, because
crystals are numbered rather than named and a group per crystal would put the index in the
path. The reader reads them back so a stored raster re-opens with what it found.

Nothing caps the list at one. A raster can find several crystals and the ordering carries the
answer: sorted by score, best first.

angle_deg is an axis, not a direction. It lies in [0, 180) and wraps, so 179 and 0 are
adjacent and two of these do not average arithmetically - a pair of nearly parallel needles
averaged across the wrap comes out as a right angle. That is stated at every layer it passes
through, since it is the kind of field a consumer will reach for without reading the producer.

The producer side is not wired yet: nothing fills ScanResult::grid or EndMessage::grid_crystals
here. The seam is receiver/JFJochReceiver.cpp, where rotation_lattice is filled in
EndDataCollection and GetFinalStatistics.

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

224 lines
10 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);
const auto grid = AnalysisModeStages(AnalysisMode::Grid);
CHECK(grid.spot_finding);
CHECK(grid.scoring);
CHECK_FALSE(grid.indexing);
}
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);
}
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");
}