Tell a Smargon head position from the spindle by equipment_component

The reader recognised chi and phi by name. phi is an ordinary spindle name in MX, so a file whose
rotation axis is called phi had it read back as a head position as well as the spindle - and writing
that experiment out again threw, because the sample chain then tried to create phi twice. In the
other direction a still with a head position had chi, its alphabetically first stationary axis,
adopted as the goniometer.

Both are now settled by the file: the axes jfjoch writes for a Smargon carry
equipment_component="smargon", the reader takes a head position only from a tagged axis, and skips
tagged axes when looking for the spindle. NXmx defines equipment_component as an identifier of the
component of the equipment a transformation belongs to, which is what this is; there is no
"equipment" attribute in NeXus at all.

Adds HDF5Object::AttrExists, since the tag is absent on every file from anywhere else.

The two tests assert on the written file - the axis length and the attribute - because the reader
cannot see either: it does not look at a shape, and it did not look at the tag. That is the same gap
that let the one-image shape through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfYvJT5Nb71suJCowRBn5z
This commit is contained in:
2026-08-23 10:49:47 +02:00
co-authored by Claude Opus 5
parent 8dd3ee6576
commit ce11cade84
6 changed files with 156 additions and 4 deletions
+127
View File
@@ -2995,3 +2995,130 @@ TEST_CASE("JFJochReader_TransformationChain_SentAndBuilt", "[HDF5][Full]") {
remove("test_chain_sent_master.h5");
REQUIRE(H5Fget_obj_count(H5F_OBJ_ALL, H5F_OBJ_ALL) == 0);
}
// A head position is not a sweep, and it is not the spindle either. Both properties are carried by
// the file itself - the axis length says how many images there are, the equipment_component tag says
// what the axis is - so both are checked here on the file, not through the reader: the reader alone
// cannot see a shape or an attribute it never looks at.
TEST_CASE("JFJochReader_Smargon_StillIsNotOneImage", "[HDF5][Full]") {
DiffractionExperiment x(DetJF(1));
x.ImagesPerTrigger(5).OverwriteExistingFiles(true).FilePrefix("test_smargon");
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.Smargon(SmargonPosition{.phi_deg = -7.25f, .chi_deg = 12.5f});
REQUIRE(!x.GetGoniometer().has_value());
RegisterHDF5Filter();
std::vector<uint16_t> 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();
{
HDF5ReadOnlyFile master("test_smargon_master.h5");
// One entry per image. A reader takes the image count from the innermost axis of the sample
// chain when no axis varies; as scalars these read back as a single image.
CHECK(master.GetDimension("/entry/sample/transformations/chi")
== std::vector<hsize_t>{static_cast<hsize_t>(x.GetImageNum())});
CHECK(master.GetDimension("/entry/sample/transformations/phi")
== std::vector<hsize_t>{static_cast<hsize_t>(x.GetImageNum())});
CHECK(master.ReadVector<double>("/entry/sample/transformations/phi")
== std::vector<double>(x.GetImageNum(), -7.25));
// Tagged, so neither is mistaken for the spindle - and so a phi from anywhere else is not
// mistaken for a head position.
HDF5DataSet chi(master, "/entry/sample/transformations/chi");
HDF5DataSet phi(master, "/entry/sample/transformations/phi");
REQUIRE(chi.AttrExists("equipment_component"));
REQUIRE(phi.AttrExists("equipment_component"));
CHECK(chi.ReadAttrStr("equipment_component") == "smargon");
CHECK(phi.ReadAttrStr("equipment_component") == "smargon");
}
const auto read = [](const std::string &prefix) {
JFJochHDF5Reader reader;
reader.ReadFile(prefix + "_master.h5");
return reader.GetDataset()->experiment;
};
const auto read_back = read("test_smargon");
// chi is the alphabetically first stationary axis in the file; it must not become the spindle.
CHECK(!read_back.GetGoniometer().has_value());
REQUIRE(read_back.GetDatasetSettings().GetSmargonPosition().has_value());
CHECK(read_back.GetDatasetSettings().GetSmargonPosition()->chi_deg == Catch::Approx(12.5f).margin(1e-3));
CHECK(read_back.GetDatasetSettings().GetSmargonPosition()->phi_deg == Catch::Approx(-7.25f).margin(1e-3));
remove("test_smargon_master.h5");
remove("test_smargon_data_000001.h5");
REQUIRE(H5Fget_obj_count(H5F_OBJ_ALL, H5F_OBJ_ALL) == 0);
}
// phi is an ordinary spindle name in MX. A file whose rotation axis is called phi carries no
// equipment_component, so it stays the spindle and no head position is invented from it - which also
// means the file can be written back out, instead of colliding on a second dataset called phi.
TEST_CASE("JFJochReader_Goniometer_NamedPhiIsNotSmargon", "[HDF5][Full]") {
DiffractionExperiment x(DetJF(1));
x.ImagesPerTrigger(5).OverwriteExistingFiles(true).FilePrefix("test_phispindle");
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.Goniometer(GoniometerAxis("phi", 30, 0.2f, Coord(0,-1,0), {}));
RegisterHDF5Filter();
std::vector<uint16_t> 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();
const auto read = [](const std::string &prefix) {
JFJochHDF5Reader reader;
reader.ReadFile(prefix + "_master.h5");
return reader.GetDataset()->experiment;
};
const auto read_back = read("test_phispindle");
REQUIRE(read_back.GetGoniometer().has_value());
CHECK(read_back.GetGoniometer()->GetName() == "phi");
CHECK(read_back.GetGoniometer()->GetStart_deg() == Catch::Approx(30).margin(1e-3));
CHECK(!read_back.GetDatasetSettings().GetSmargonPosition().has_value());
// Writing what was read must not try to create phi a second time.
DiffractionExperiment rewrite = read_back;
rewrite.FilePrefix("test_phispindle_out").OverwriteExistingFiles(true);
StartMessage out_start;
rewrite.FillMessage(out_start);
FileWriter out(out_start);
EndMessage out_end;
out_end.max_image_number = rewrite.GetImageNum();
REQUIRE_NOTHROW(out.WriteHDF5(out_end));
REQUIRE_NOTHROW(out.Finalize());
remove("test_phispindle_master.h5");
remove("test_phispindle_data_000001.h5");
remove("test_phispindle_out_master.h5");
REQUIRE(H5Fget_obj_count(H5F_OBJ_ALL, H5F_OBJ_ALL) == 0);
}