diff --git a/reader/HDF5MetadataSource.cpp b/reader/HDF5MetadataSource.cpp index 03eb1405..93755c1a 100644 --- a/reader/HDF5MetadataSource.cpp +++ b/reader/HDF5MetadataSource.cpp @@ -1124,7 +1124,13 @@ std::optional HDF5MetadataSource::ReadAxis(HDF5Object *file, con if (angle.empty()) return {}; - if (dataset.ReadAttrStr("transformation_type") != "rotation") + // Not everything in the group is an axis. The writer's own AXISNAME_end and the two rotation + // width scalars carry only units, and a file from anywhere else may hold whatever it likes. + // Missing attribute means "not a transformation", so skip it rather than throwing: the search + // for the goniometer walks every leaf and only stops early on an axis that turns, so a master + // whose axis was stationary reached omega_end and could not be opened at all. + if (!dataset.AttrExists("transformation_type") + || (dataset.ReadAttrStr("transformation_type") != "rotation")) return {}; std::vector end = file->ReadOptVector(dname + "_end"); diff --git a/tests/JFJochReaderTest.cpp b/tests/JFJochReaderTest.cpp index ac6f2369..64bab23c 100644 --- a/tests/JFJochReaderTest.cpp +++ b/tests/JFJochReaderTest.cpp @@ -3010,6 +3010,124 @@ TEST_CASE("JFJochReader_TransformationChain_SentAndBuilt", "[HDF5][Full]") { REQUIRE(H5Fget_obj_count(H5F_OBJ_ALL, H5F_OBJ_ALL) == 0); } +// Recovering the sample axes from a written file, across the configurations the writer produces. +// The reader searches every leaf of /entry/sample/transformations, so on the way it meets the +// writer's own AXISNAME_end and rotation-width datasets, which are not axes and carry no +// transformation_type. It used to throw on them: a master whose axis did not turn never stopped the +// search early, walked into omega_end and could not be opened at all - which took out rugnux's own +// output for a grid scan. +TEST_CASE("JFJochReader_AxisRecovery", "[HDF5][Full]") { + RegisterHDF5Filter(); + + const auto round_trip = [](DiffractionExperiment x, const std::string &prefix) { + x.FilePrefix(prefix).OverwriteExistingFiles(true) + .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)); + std::vector image(x.GetPixelsNum(), 0); + StartMessage start_message; + x.FillMessage(start_message); + { + FileWriter file_set(start_message); + DataMessage message{}; + for (int i = 0; i < x.GetImageNum(); i++) { + message.image = CompressedImage(image, x.GetXPixelsNum(), x.GetYPixelsNum()); + message.number = i; + REQUIRE_NOTHROW(file_set.WriteHDF5(message)); + } + EndMessage end_message; + end_message.max_image_number = x.GetImageNum(); + file_set.WriteHDF5(end_message); + file_set.Finalize(); + } + JFJochHDF5Reader reader; + REQUIRE_NOTHROW(reader.ReadFile(prefix + "_master.h5")); + return reader.GetDataset()->experiment; + }; + const auto cleanup = [](const std::string &prefix) { + remove((prefix + "_master.h5").c_str()); + remove((prefix + "_data_000001.h5").c_str()); + }; + + SECTION("a sweep") { + DiffractionExperiment x(DetJF(1)); + x.ImagesPerTrigger(5).Goniometer(GoniometerAxis("omega", 95, 0.1f, Coord(0,-1,0), {})); + const auto out = round_trip(x, "test_ax_sweep"); + REQUIRE(out.GetGoniometer().has_value()); + CHECK(out.GetGoniometer()->GetName() == "omega"); + CHECK(out.GetGoniometer()->IsScanning()); + CHECK(out.GetGoniometer()->GetStart_deg() == Catch::Approx(95).margin(1e-3)); + CHECK(out.GetGoniometer()->GetIncrement_deg() == Catch::Approx(0.1).margin(1e-4)); + CHECK(out.GetGoniometer()->GetAxis() == Coord(0,-1,0)); + cleanup("test_ax_sweep"); + } + + SECTION("a sweep about an axis that is not called omega") { + DiffractionExperiment x(DetJF(1)); + x.ImagesPerTrigger(5).Goniometer(GoniometerAxis("kappa", 10, 0.5f, Coord(0,-1,0), {})); + const auto out = round_trip(x, "test_ax_kappa"); + REQUIRE(out.GetGoniometer().has_value()); + CHECK(out.GetGoniometer()->GetName() == "kappa"); + CHECK(out.GetGoniometer()->IsScanning()); + CHECK(out.GetGoniometer()->GetIncrement_deg() == Catch::Approx(0.5).margin(1e-4)); + cleanup("test_ax_kappa"); + } + + SECTION("a spindle that does not turn") { + DiffractionExperiment x(DetJF(1)); + x.ImagesPerTrigger(5).Goniometer(GoniometerAxis("omega", 12.5f, 0.0f, Coord(0,-1,0), {})); + const auto out = round_trip(x, "test_ax_still"); + REQUIRE(out.GetGoniometer().has_value()); + CHECK(out.GetGoniometer()->GetName() == "omega"); + CHECK(!out.GetGoniometer()->IsScanning()); + CHECK(out.GetGoniometer()->GetStart_deg() == Catch::Approx(12.5).margin(1e-3)); + cleanup("test_ax_still"); + } + + SECTION("a grid scan, which sits on a spindle that does not turn") { + DiffractionExperiment x(DetJF(1)); + x.ImagesPerTrigger(6).GridScan(GridScanSettings(3, 10.0f, 20.0f, false, false).ImageNum(6)); + const auto out = round_trip(x, "test_ax_grid"); + REQUIRE(out.GetGridScan().has_value()); + CHECK(out.GetGridScan()->GetNFast() == 3); + CHECK(out.GetGridScan()->GetGridStepX_um() == Catch::Approx(10.0).margin(1e-3)); + CHECK(out.GetGridScan()->GetGridStepY_um() == Catch::Approx(20.0).margin(1e-3)); + // NXmx cannot say "no rotation", so the writer records the spindle standing still. + REQUIRE(out.GetGoniometer().has_value()); + CHECK(!out.GetGoniometer()->IsScanning()); + cleanup("test_ax_grid"); + } + + SECTION("a grid scan under a turning spindle") { + DiffractionExperiment x(DetJF(1)); + x.ImagesPerTrigger(6).GridScan(GridScanSettings(3, 10.0f, 20.0f, false, false).ImageNum(6)) + .Goniometer(GoniometerAxis("omega", 0, 0.2f, Coord(0,-1,0), {})); + const auto out = round_trip(x, "test_ax_gridsweep"); + REQUIRE(out.GetGridScan().has_value()); + CHECK(out.GetGridScan()->GetNFast() == 3); + REQUIRE(out.GetGoniometer().has_value()); + CHECK(out.GetGoniometer()->IsScanning()); + CHECK(out.GetGoniometer()->GetIncrement_deg() == Catch::Approx(0.2).margin(1e-4)); + cleanup("test_ax_gridsweep"); + } + + SECTION("a sweep with the head at a Smargon position") { + DiffractionExperiment x(DetJF(1)); + x.ImagesPerTrigger(5).Goniometer(GoniometerAxis("omega", 95, 0.1f, Coord(0,-1,0), {})) + .Smargon(SmargonPosition{.phi_deg = -7.25f, .chi_deg = 12.5f}); + const auto out = round_trip(x, "test_ax_smargon"); + REQUIRE(out.GetGoniometer().has_value()); + CHECK(out.GetGoniometer()->GetName() == "omega"); + CHECK(out.GetGoniometer()->IsScanning()); + REQUIRE(out.GetDatasetSettings().GetSmargonPosition().has_value()); + CHECK(out.GetDatasetSettings().GetSmargonPosition()->chi_deg == Catch::Approx(12.5).margin(1e-3)); + CHECK(out.GetDatasetSettings().GetSmargonPosition()->phi_deg == Catch::Approx(-7.25).margin(1e-3)); + cleanup("test_ax_smargon"); + } + + REQUIRE(H5Fget_obj_count(H5F_OBJ_ALL, H5F_OBJ_ALL) == 0); +} + // saturation_value is written inclusive and used exclusive, so a read has to add the count back. It // did not, and the value fell by one on every write-read-write cycle - unbounded, and compounding // whenever a _process.h5 was reprocessed. Nothing caught it: no test asserted the read-back limit.