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
+17 -13
View File
@@ -359,23 +359,28 @@ inline void CBOR_ENC_GRID_SCAN(CborEncoder &encoder, const char* key, const Grid
}
inline void CBOR_ENC_TRANSFORMATIONS(CborEncoder &encoder, const char* key,
const std::vector<TransformationAxis> &chain) {
const std::vector<DetectorTransformation> &chain) {
if (chain.empty())
return;
CborEncoder arrayEncoder, mapEncoder;
cborErr(cbor_encode_text_stringz(&encoder, key));
// An ARRAY, not a map: the order is the mounting order and must survive. A CBOR map cannot carry
// that - RFC 8949 requires deterministic encoders to sort map keys - which is why this exists
// beside the (unordered, DECTRIS-defined) goniometer map rather than replacing it.
// An ARRAY, not a map: the chain is ordered, base first, and a CBOR map carries no order a
// consumer may rely on - RFC 8949 has deterministic encoders sort map keys.
cborErr(cbor_encoder_create_array(&encoder, &arrayEncoder, chain.size()));
for (const auto &axis: chain) {
cborErr(cbor_encoder_create_map(&arrayEncoder, &mapEncoder, 5));
CBOR_ENC(mapEncoder, "name", axis.name);
CBOR_ENC(mapEncoder, "type", axis.rotation ? "rotation" : "translation");
CBOR_ENC(mapEncoder, "vector", axis.vector);
CBOR_ENC(mapEncoder, "start", static_cast<float>(axis.start));
CBOR_ENC(mapEncoder, "increment", static_cast<float>(axis.increment));
cborErr(cbor_encoder_create_map(&arrayEncoder, &mapEncoder, CborIndefiniteLength));
CBOR_ENC(mapEncoder, "name", axis.GetName());
CBOR_ENC(mapEncoder, "transformation_type", axis.IsRotation() ? "rotation" : "translation");
CBOR_ENC(mapEncoder, "units", axis.GetUnits());
CBOR_ENC(mapEncoder, "vector", axis.GetVector());
CBOR_ENC(mapEncoder, "offset", axis.GetOffset());
CBOR_ENC(mapEncoder, "depends_on", axis.GetDependsOn());
if (!axis.GetEquipment().empty())
CBOR_ENC(mapEncoder, "equipment", axis.GetEquipment());
if (!axis.GetEquipmentComponent().empty())
CBOR_ENC(mapEncoder, "equipment_component", axis.GetEquipmentComponent());
CBOR_ENC(mapEncoder, "values", axis.GetValues());
cborErr(cbor_encoder_close_container(&arrayEncoder, &mapEncoder));
}
cborErr(cbor_encoder_close_container(&encoder, &arrayEncoder));
@@ -711,9 +716,6 @@ void CBORStream2Serializer::SerializeSequenceStart(const StartMessage& message)
CBOR_ENC(mapEncoder, "series_id", message.run_number);
CBOR_ENC(mapEncoder, "fluorescence", message.fluorescence_spectrum);
// The ordered chain, and beside it the DECTRIS-shaped goniometer map and the grid scan, which a
// consumer that predates the chain still understands. Both describe the same setup.
CBOR_ENC_TRANSFORMATIONS(mapEncoder, "transformations", message.transformations);
if (message.goniometer)
CBOR_ENC_GONIOMETER_MAP(mapEncoder, "goniometer", message);
if (message.grid_scan)
@@ -777,6 +779,8 @@ void CBORStream2Serializer::SerializeSequenceEnd(const EndMessage& message) {
CBOR_ENC(mapEncoder, "end_date", message.end_date);
CBOR_ENC(mapEncoder, "max_image_number", message.max_image_number);
// Optional: absent means the writer builds the same chain from the start message itself.
CBOR_ENC_TRANSFORMATIONS(mapEncoder, "transformations", message.transformations);
CBOR_ENC(mapEncoder, "images_collected", message.images_collected_count);
CBOR_ENC(mapEncoder, "images_sent_to_write", message.images_sent_to_write_count);
CBOR_ENC(mapEncoder, "data_collection_efficiency", message.efficiency);