Carry the sample transformation chain as DetectorTransformation

Replaces the start-message TransformationAxis of the previous commit, which was
the wrong shape in two ways.

DetectorTransformation (common/) mirrors a NeXus NXtransformations axis and holds
nothing else: name, type, units, vector, offset, depends_on and the positions
themselves. Deliberately without cleverness - the values are either a single
number for an axis that does not move or one per image, and nothing derives a
position from a start and an increment. That is the point: a producer will later
want to report where a stage actually WENT rather than where it was told to go,
and a structure that stores start+increment cannot express that. A million images
cost 4 MB per axis, which is not a reason to be clever.

Hence also the move to the END message: measured positions are only known once
the run is over.

And hence no metadata version bump, which the previous commit did make. The chain
is optional; when it is absent the writer builds the identical chain from the
start message, exactly as before. Nothing on the wire changes for a producer that
does not send it, so a broker and a writer of different releases still interwork -
the constraint the previous version stated is withdrawn.

The writer transcribes a chain it is given, without recomputing an angle, which
is what makes measured positions possible end to end.

JFJochReader_TransformationChain_SentAndBuilt writes the same run both ways and
checks the two files read back the same, chi/phi included.
CBORSerialize_End_Transformations covers the wire 1:1, asserting the order
survives and that a moving axis keeps one value per image while a stationary one
keeps a single value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 23:36:50 +02:00
co-authored by Claude Opus 5
parent 7efcf631de
commit e4edcd6fa9
13 changed files with 404 additions and 102 deletions
+43 -20
View File
@@ -717,7 +717,6 @@ void DiffractionExperiment::FillMessage(StartMessage &message) const {
message.goniometer = dataset.GetGoniometer();
message.grid_scan = dataset.GetGridScan();
message.transformations = BuildTransformationChain();
message.run_number = GetRunNumber();
message.run_name = GetRunName();
@@ -1457,30 +1456,54 @@ bool DiffractionExperiment::IsDetectorMirroredY() const {
}
// Base first, sample last - the order things are physically mounted in, which is also the order an
// NXmx depends_on chain has to be built in. The grid stage is a base stage (an Aerotech xyz at SLS)
// that the spindle sits on; the spindle carries the head; the head carries the sample.
std::vector<TransformationAxis> DiffractionExperiment::BuildTransformationChain() const {
std::vector<TransformationAxis> chain;
// NXmx depends_on chain composes in. The grid stage is a base stage (an Aerotech xyz at SLS) that
// the spindle sits on; the spindle carries the head; the head carries the sample.
//
// An axis that moves carries one value per image; one that does not carries a single value. Nothing
// is derived on the way out to NXmx - that is the point of holding the positions rather than a start
// and an increment, so that measured positions can be carried here later without changing anything
// downstream.
std::vector<DetectorTransformation> DiffractionExperiment::BuildTransformationChain(int64_t image_num) const {
std::vector<DetectorTransformation> chain;
std::string parent; // empty = mounted on the base
if (const auto grid_scan = GetGridScan()) {
chain.push_back({.name = "grid_scan_x", .rotation = false, .vector = {1, 0, 0}});
chain.push_back({.name = "grid_scan_y", .rotation = false, .vector = {0, 1, 0}});
const auto add = [&chain, &parent](DetectorTransformation axis) {
axis.DependsOn(parent);
parent = axis.GetName();
chain.push_back(std::move(axis));
};
const auto to_float = [](const std::vector<double> &input) {
return std::vector<float>(input.begin(), input.end());
};
if (const auto grid_scan = GetGridScan(); grid_scan.has_value() && (image_num > 0)) {
add(DetectorTransformation("grid_scan_x", TransformationType::Translation, {1, 0, 0})
.Values(to_float(grid_scan->GetXContainer_m(image_num))));
add(DetectorTransformation("grid_scan_y", TransformationType::Translation, {0, 1, 0})
.Values(to_float(grid_scan->GetYContainer_m(image_num))));
}
if (const auto goniometer = GetGoniometer())
chain.push_back({.name = goniometer->GetName(),
.rotation = true,
.vector = goniometer->GetAxis(),
.start = goniometer->GetStart_deg(),
.increment = goniometer->GetIncrement_deg()});
if (const auto goniometer = GetGoniometer()) {
DetectorTransformation axis(goniometer->GetName(), TransformationType::Rotation,
goniometer->GetAxis());
if (goniometer->IsScanning() && (image_num > 0))
axis.Values(to_float(goniometer->GetAngleContainer(image_num)));
else
axis.Value(goniometer->GetStart_deg());
add(std::move(axis));
} else if (GetGridScan().has_value()) {
// A grid scan still sits on a spindle that simply does not turn. NXmx cannot say "there is no
// rotation", and a sample chain of translations alone is not something readers accept.
add(DetectorTransformation("omega", TransformationType::Rotation, {-1, 0, 0}).Value(0.0f));
}
// Smargon chi and phi are ordinary axes that happen not to move; they are only separate in the
// settings for historical reasons.
// Smargon chi and phi are ordinary axes that happen not to move.
if (const auto smargon = dataset.GetSmargonPosition()) {
chain.push_back({.name = "chi", .rotation = true,
.vector = smargon->chi_axis, .start = smargon->chi_deg});
chain.push_back({.name = "phi", .rotation = true,
.vector = smargon->phi_axis, .start = smargon->phi_deg});
add(DetectorTransformation("chi", TransformationType::Rotation, smargon->chi_axis)
.Value(smargon->chi_deg));
add(DetectorTransformation("phi", TransformationType::Rotation, smargon->phi_axis)
.Value(smargon->phi_deg));
}
return chain;