diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt index 0884f5bb..3b7b3508 100644 --- a/common/CMakeLists.txt +++ b/common/CMakeLists.txt @@ -102,6 +102,8 @@ ADD_LIBRARY(JFJochCommon STATIC JFJochMessages.h GoniometerAxis.cpp GoniometerAxis.h + DetectorTransformation.cpp + DetectorTransformation.h CompressedImage.cpp CompressedImage.h Reflection.h diff --git a/common/DetectorTransformation.cpp b/common/DetectorTransformation.cpp new file mode 100644 index 00000000..73d22a0b --- /dev/null +++ b/common/DetectorTransformation.cpp @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include + +#include "DetectorTransformation.h" + +DetectorTransformation::DetectorTransformation(std::string name, TransformationType type, + const Coord &vector) + : name(std::move(name)), type(type), vector(vector) { + units = (type == TransformationType::Rotation) ? "deg" : "m"; +} + +DetectorTransformation &DetectorTransformation::Name(const std::string &input) { + name = input; + return *this; +} + +DetectorTransformation &DetectorTransformation::Type(TransformationType input) { + type = input; + return *this; +} + +DetectorTransformation &DetectorTransformation::Units(const std::string &input) { + units = input; + return *this; +} + +DetectorTransformation &DetectorTransformation::Vector(const Coord &input) { + vector = input; + return *this; +} + +DetectorTransformation &DetectorTransformation::Offset(const Coord &input) { + offset = input; + return *this; +} + +DetectorTransformation &DetectorTransformation::DependsOn(const std::string &input) { + depends_on = input; + return *this; +} + +DetectorTransformation &DetectorTransformation::Equipment(const std::string &input) { + equipment = input; + return *this; +} + +DetectorTransformation &DetectorTransformation::EquipmentComponent(const std::string &input) { + equipment_component = input; + return *this; +} + +DetectorTransformation &DetectorTransformation::Values(const std::vector &input) { + values = input; + return *this; +} + +DetectorTransformation &DetectorTransformation::Value(float input) { + values = {input}; + return *this; +} + +const std::string &DetectorTransformation::GetName() const { + return name; +} + +TransformationType DetectorTransformation::GetType() const { + return type; +} + +const std::string &DetectorTransformation::GetUnits() const { + return units; +} + +Coord DetectorTransformation::GetVector() const { + return vector; +} + +Coord DetectorTransformation::GetOffset() const { + return offset; +} + +const std::string &DetectorTransformation::GetDependsOn() const { + return depends_on; +} + +const std::string &DetectorTransformation::GetEquipment() const { + return equipment; +} + +const std::string &DetectorTransformation::GetEquipmentComponent() const { + return equipment_component; +} + +const std::vector &DetectorTransformation::GetValues() const { + return values; +} + +bool DetectorTransformation::IsRotation() const { + return type == TransformationType::Rotation; +} + +bool DetectorTransformation::IsConstant() const { + return values.size() <= 1; +} diff --git a/common/DetectorTransformation.h b/common/DetectorTransformation.h new file mode 100644 index 00000000..4208f58e --- /dev/null +++ b/common/DetectorTransformation.h @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include +#include + +#include "Coord.h" + +enum class TransformationType { Rotation, Translation }; + +// One axis of a NeXus NXtransformations chain, held the way NXmx needs it written and nothing more. +// +// Deliberately without any cleverness: the values are either a single number, meaning an axis that +// does not move, or one number per image. Nothing here derives a position from a start and an +// increment, because the point of the structure is to be able to carry positions that were MEASURED +// rather than inferred - a custom scan, a stage that did not go exactly where it was asked. A +// million images cost 4 MB per axis at this precision, which is not a reason to be clever. +// +// A chain is a std::vector ordered base first, sample last - the order +// things are mounted in, which is also the order NXmx composes them in. depends_on names the axis +// this one is mounted on, and is empty for the base. +class DetectorTransformation { + std::string name; + TransformationType type = TransformationType::Rotation; + std::string units = "deg"; + Coord vector = {0, 0, 1}; + Coord offset = {0, 0, 0}; + std::string depends_on; // empty = mounted on the base ("." in NXmx) + std::string equipment; + std::string equipment_component; + std::vector values; // one entry = constant, otherwise one per image + +public: + DetectorTransformation() = default; + DetectorTransformation(std::string name, TransformationType type, const Coord &vector); + + DetectorTransformation& Name(const std::string &input); + DetectorTransformation& Type(TransformationType input); + DetectorTransformation& Units(const std::string &input); + DetectorTransformation& Vector(const Coord &input); + DetectorTransformation& Offset(const Coord &input); + DetectorTransformation& DependsOn(const std::string &input); + DetectorTransformation& Equipment(const std::string &input); + DetectorTransformation& EquipmentComponent(const std::string &input); + DetectorTransformation& Values(const std::vector &input); + DetectorTransformation& Value(float input); // an axis that does not move + + [[nodiscard]] const std::string& GetName() const; + [[nodiscard]] TransformationType GetType() const; + [[nodiscard]] const std::string& GetUnits() const; + [[nodiscard]] Coord GetVector() const; + [[nodiscard]] Coord GetOffset() const; + [[nodiscard]] const std::string& GetDependsOn() const; + [[nodiscard]] const std::string& GetEquipment() const; + [[nodiscard]] const std::string& GetEquipmentComponent() const; + [[nodiscard]] const std::vector& GetValues() const; + + [[nodiscard]] bool IsRotation() const; + // True when the axis holds one value for the whole run rather than one per image. + [[nodiscard]] bool IsConstant() const; +}; diff --git a/common/DiffractionExperiment.cpp b/common/DiffractionExperiment.cpp index a004a1c5..76395722 100644 --- a/common/DiffractionExperiment.cpp +++ b/common/DiffractionExperiment.cpp @@ -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 DiffractionExperiment::BuildTransformationChain() const { - std::vector 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 DiffractionExperiment::BuildTransformationChain(int64_t image_num) const { + std::vector 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 &input) { + return std::vector(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; diff --git a/common/DiffractionExperiment.h b/common/DiffractionExperiment.h index 9e7d06e1..79b61341 100644 --- a/common/DiffractionExperiment.h +++ b/common/DiffractionExperiment.h @@ -391,8 +391,9 @@ public: bool IsDetectorModuleSync() const; bool IsDetectorMirroredY() const; - // The sample transformation chain in mounting order, base first. See TransformationAxis. - [[nodiscard]] std::vector BuildTransformationChain() const; + // The sample transformation chain in mounting order, base first (see DetectorTransformation). + // image_num is needed because a moving axis carries one value per image. + [[nodiscard]] std::vector BuildTransformationChain(int64_t image_num) const; [[nodiscard]] DetectorType GetDetectorType() const; [[nodiscard]] bool IsMaskPixelsWithoutG0() const; diff --git a/common/JFJochMessages.h b/common/JFJochMessages.h index 3943028f..081de0f1 100644 --- a/common/JFJochMessages.h +++ b/common/JFJochMessages.h @@ -16,6 +16,7 @@ #include "SpotToSave.h" #include "UnitCell.h" #include "GoniometerAxis.h" +#include "DetectorTransformation.h" #include "GridScanSettings.h" #include "Reflection.h" #include "CrystalLattice.h" @@ -23,7 +24,7 @@ #include "XrayFluorescenceSpectrum.h" #include "../gemmi_gph/gemmi/symmetry.hpp" -constexpr const uint64_t user_data_release = 7; +constexpr const uint64_t user_data_release = 6; constexpr const uint64_t user_data_magic_number = 0x52320000UL | user_data_release; enum class CBORImageType {START, END, IMAGE, CALIBRATION, METADATA, NONE}; @@ -78,23 +79,6 @@ struct LatticeMessage { gemmi::CrystalSystem crystal_system; }; -// One axis of the sample's transformation chain, in mounting order: base first, sample last. -// -// A CBOR map has no ordering a consumer may rely on - RFC 8949 requires deterministic encoders to -// SORT map keys - so the chain cannot be expressed as the `goniometer` map, which is a DECTRIS -// stream2 field and keyed by axis name. This is sent instead, as an ordered array, and the -// `goniometer` map is still emitted beside it for consumers that only know stream2. -// -// Rotations carry their own angles here. Translations name the chain position only; their per-image -// positions come from the grid scan, which knows the image count and this does not. -struct TransformationAxis { - std::string name; - bool rotation = true; // false = translation, driven by the grid scan - Coord vector = {0, 0, 1}; - double start = 0; // deg for a rotation; unused for a translation - double increment = 0; // deg per image; 0 means the axis does not move -}; - struct SmargonPosition { float phi_deg = 0; float chi_deg = 0; @@ -287,9 +271,6 @@ struct StartMessage { std::vector rois; std::optional grid_scan; - // The sample chain, base -> sample. Authoritative when non-empty; the goniometer map and the - // grid scan remain for consumers that predate it. - std::vector transformations; std::optional goniometer; float detector_translation[3]; @@ -357,6 +338,12 @@ struct StartMessage { }; struct EndMessage { + // The sample transformation chain, base first (see DetectorTransformation). Optional: when it is + // empty the writer builds the same chain itself from the start message, so a producer that does + // not send it loses nothing and the wire format needs no version bump. It lives in the END + // message because a future producer will want to report where the stage actually WENT, and that + // is only known once the run is over. + std::vector transformations; uint64_t max_image_number; // Counting from 1, i.e. 0 = no images collected std::optional images_collected_count; std::optional images_sent_to_write_count; diff --git a/docs/CBOR.md b/docs/CBOR.md index 8a4ed9cd..b961e8ff 100644 --- a/docs/CBOR.md +++ b/docs/CBOR.md @@ -1,11 +1,5 @@ # CBOR messages -> **Metadata version 7** adds the ordered `transformations` chain and allows a goniometer axis and a -> grid scan to be sent together. A broker and a writer of different releases must not be mixed across -> this change: an older writer ignores `transformations` and reads only the unordered `goniometer` -> map, so a chain whose order matters - anything with a Smargon chi/phi, or a grid scan combined with -> a rotation - is not reproduced. `magic_number` carries the version. - To communicate between FPGA-equipped receiver system and writers, Jungfraujoch is using binary CBOR encoding with tinycbor library (Intel). The protocol is based on and compatible with [DECTRIS Stream2](https://github.com/dectris/documentation/tree/main/stream_v2). @@ -60,7 +54,6 @@ There are minor differences at the moment: | - - helical_step | Array(float) | Translation for helical scan for 1 image \[m\] | | | - - screening_wedge | Array(float) | Wedge for screening \[deg\] (increment would correspond to difference between screening points) | | | grid_scan | object | Grid scan definition (optional). May be sent together with `goniometer`: a grid is often collected at a given head position, recorded as a stationary axis (step 0) | | -| transformations | Array(object) | The sample transformation chain in mounting order, base first: each element has `name`, `type` (`rotation`/`translation`), `vector`, `start` \[deg\], `increment` \[deg/image\]. An ARRAY because the order matters and a CBOR map has none - RFC 8949 has deterministic encoders sort map keys. Authoritative when present; `goniometer` and `grid_scan` describe the same setup for consumers that predate it | | | - n_fast | uint64 | Number of elements along fast axis | | | - n_slow | uint64 | Number of elements along slow axis | | | - step_x_axis | float | Step along X axis, can be negative \[m\] | | @@ -293,6 +286,7 @@ See [DECTRIS documentation](https://github.com/dectris/documentation/tree/main/s | series_id | uint64 | Unique numeric ID of the series (run_number parameter) | X | | end_date | string | Approximate end date | | | max_image_number | uint64 | Number of image with the highest number; counted from 1 to distinguish zero images and one image | | +| transformations | Array(object) (optional) | Sample transformation chain in mounting order, base first. Each element mirrors a NeXus NXtransformations axis: `name`, `transformation_type` (`rotation`/`translation`), `units`, `vector`, `offset`, `depends_on` (the axis this one is mounted on, empty for the base), and `values` - a single number for an axis that does not move, otherwise one per image. An ARRAY because the order matters and a CBOR map has none. Optional: when absent the writer builds the same chain from the start message. It is in the END message because a producer may want to report positions that were **measured** rather than commanded, which are only known once the run is over | | | images_collected | uint64 | Number of images collected | | | images_sent_to_write | uint64 | Number of images sent to writer; if writer queues were full, it is possible this is less than images collected | | | data_collection_efficiency | float | Overall network packets collected / network packets expected | | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a7f2ede4..1b73a6d6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -16,10 +16,8 @@ This is an UNSTABLE release. It includes many experimental features, as well as * HDF5: a data file missing next to a VDS master now reads as the error-pixel marker instead of zero counts, so those frames are masked rather than silently integrated as blank. * The writer refuses a stream whose start message declares a different pixel format than its images carry, instead of writing a master that does not describe its own data. * HDF5: `module_offset` is written as a float with a proper unit vector, and every transformation offset declares `offset_units`, so a reader does not fall back to the axis's own units - degrees on a rotation - when interpreting a length. -* The image stream carries the sample transformation chain (`transformations`) in mounting order, so a goniometer axis, the Smargon chi/phi and a grid stage can be described together and unambiguously. +* The image stream can carry the sample transformation chain (`transformations`, in the END message) in mounting order, so a goniometer axis, the Smargon chi/phi and a grid stage are described together and unambiguously. It is optional - a producer that does not send it gets the same chain built by the writer - so no metadata version change is needed. * Smargon chi/phi are written for a still as well, and are read back from HDF5; before, they were dropped unless the run also had a rotation axis or a grid scan, and nothing read them. - -**CBOR metadata version 7** - a broker and a writer from different releases must not be mixed: an older writer ignores `transformations` and sees only the unordered `goniometer` map. * The image stream and HDF5 now record `mirror_y`, whether the assembled image is mirrored in Y relative to the detector's raw readout, and it is read back. * rugnux: the `.poni` file declares pyFAI's `orientation`, so pyFAI no longer assumes its own default and get the azimuth sense backwards; radial integration is unchanged. * A grid scan and a goniometer axis are no longer alternatives - both can be set, and the grid scan is no longer silently dropped when an axis is present. diff --git a/frame_serialize/CBORStream2Deserializer.cpp b/frame_serialize/CBORStream2Deserializer.cpp index eec3abd0..458f5341 100644 --- a/frame_serialize/CBORStream2Deserializer.cpp +++ b/frame_serialize/CBORStream2Deserializer.cpp @@ -1015,10 +1015,9 @@ namespace { } } - // The ordered sample chain (see TransformationAxis). An array, so the mounting order survives - - // a CBOR map's order carries no guarantee. Sent alongside the goniometer map, which says the same - // thing without the order for consumers that only know DECTRIS stream2. - void ProcessTransformations(StartMessage &message, CborValue &value) { + // The ordered sample chain, 1:1 with DetectorTransformation. An array, so the mounting order + // survives; a CBOR map carries no order a consumer may rely on. + void ProcessTransformations(std::vector &chain, CborValue &value) { CborValue array_value; cborErr(cbor_value_enter_container(&value, &array_value)); @@ -1026,28 +1025,39 @@ namespace { CborValue map_value; cborErr(cbor_value_enter_container(&array_value, &map_value)); - TransformationAxis axis; + DetectorTransformation axis; + std::vector values; while (!cbor_value_at_end(&map_value)) { const auto key = GetCBORString(map_value); if (key == "name") - axis.name = GetCBORString(map_value); - else if (key == "type") - axis.rotation = (GetCBORString(map_value) == "rotation"); + axis.Name(GetCBORString(map_value)); + else if (key == "transformation_type") + axis.Type(GetCBORString(map_value) == "rotation" ? TransformationType::Rotation + : TransformationType::Translation); + else if (key == "units") + axis.Units(GetCBORString(map_value)); else if (key == "vector") - axis.vector = GetCoord(map_value); - else if (key == "start") - axis.start = GetCBORFloat(map_value); - else if (key == "increment") - axis.increment = GetCBORFloat(map_value); + axis.Vector(GetCoord(map_value)); + else if (key == "offset") + axis.Offset(GetCoord(map_value)); + else if (key == "depends_on") + axis.DependsOn(GetCBORString(map_value)); + else if (key == "equipment") + axis.Equipment(GetCBORString(map_value)); + else if (key == "equipment_component") + axis.EquipmentComponent(GetCBORString(map_value)); + else if (key == "values") + GetCBORFloatArray(map_value, values); else cbor_value_advance(&map_value); } cborErr(cbor_value_leave_container(&array_value, &map_value)); - if (axis.name.empty()) + if (axis.GetName().empty()) throw JFJochException(JFJochExceptionCategory::CBORError, "Transformation axis without a name"); - message.transformations.push_back(axis); + axis.Values(values); + chain.push_back(axis); } cborErr(cbor_value_leave_container(&value, &array_value)); } @@ -1298,8 +1308,6 @@ namespace { ProcessAxis(value, message.detector_translation); else if (key == "goniometer") ProcessGoniometerMap(message, value); - else if (key == "transformations") - ProcessTransformations(message, value); else if (key == "grid_scan") message.grid_scan = ProcessGridScan(value); else if (key == "pixel_mask_enabled") @@ -1435,6 +1443,8 @@ namespace { message.run_name = GetCBORString(value); else if (key == "series_id") message.run_number = GetCBORUInt(value); + else if (key == "transformations") + ProcessTransformations(message.transformations, value); else if (key == "max_image_number") message.max_image_number = GetCBORUInt(value); else if (key == "images_collected") diff --git a/frame_serialize/CBORStream2Serializer.cpp b/frame_serialize/CBORStream2Serializer.cpp index 16c01e85..8aa7f343 100644 --- a/frame_serialize/CBORStream2Serializer.cpp +++ b/frame_serialize/CBORStream2Serializer.cpp @@ -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 &chain) { + const std::vector &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(axis.start)); - CBOR_ENC(mapEncoder, "increment", static_cast(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); diff --git a/tests/CBORTest.cpp b/tests/CBORTest.cpp index d78a158e..4b55f6a5 100644 --- a/tests/CBORTest.cpp +++ b/tests/CBORTest.cpp @@ -1320,34 +1320,51 @@ TEST_CASE("CBORSerialize_Image_LatticeType", "[CBOR][Lattice]") { // The chain is sent as an ARRAY because the mounting order has to survive, and a CBOR map's order // carries no guarantee - RFC 8949 requires deterministic encoders to sort map keys. This asserts the -// order round trips, not merely the contents. -TEST_CASE("CBORSerialize_Start_Transformations", "[CBOR]") { - StartMessage message{}; +// order round trips, not merely the contents, and that a moving axis keeps one value per image +// while a stationary one keeps a single value. +TEST_CASE("CBORSerialize_End_Transformations", "[CBOR]") { + EndMessage message{}; + message.max_image_number = 4; message.transformations = { - {.name = "grid_scan_x", .rotation = false, .vector = {1, 0, 0}}, - {.name = "grid_scan_y", .rotation = false, .vector = {0, 1, 0}}, - {.name = "omega", .rotation = true, .vector = {-1, 0, 0}, .start = 95.0, .increment = 0.1}, - {.name = "chi", .rotation = true, .vector = {0, 0, 1}, .start = 12.5}, - {.name = "phi", .rotation = true, .vector = {1, 0, 0}, .start = -7.25}, + DetectorTransformation("grid_scan_x", TransformationType::Translation, {1, 0, 0}) + .Values({0.0f, 1e-4f, 2e-4f, 3e-4f}), + DetectorTransformation("omega", TransformationType::Rotation, {-1, 0, 0}) + .DependsOn("grid_scan_x").Values({95.0f, 95.1f, 95.2f, 95.3f}), + DetectorTransformation("chi", TransformationType::Rotation, {0, 0, 1}) + .DependsOn("omega").Value(12.5f), + DetectorTransformation("phi", TransformationType::Rotation, {1, 0, 0}) + .DependsOn("chi").Value(-7.25f).Offset({1, 2, 3}), }; std::vector buffer(MESSAGE_SIZE_FOR_START_END); CBORStream2Serializer serializer(buffer.data(), buffer.size()); - REQUIRE_NOTHROW(serializer.SerializeSequenceStart(message)); + REQUIRE_NOTHROW(serializer.SerializeSequenceEnd(message)); buffer.resize(serializer.GetBufferSize()); auto output = CBORStream2Deserialize(buffer.data(), buffer.size()); - REQUIRE(output->start_message.has_value()); - const auto &chain = output->start_message->transformations; + REQUIRE(output->end_message.has_value()); + const auto &chain = output->end_message->transformations; REQUIRE(chain.size() == message.transformations.size()); for (size_t i = 0; i < chain.size(); i++) { - CHECK(chain[i].name == message.transformations[i].name); - CHECK(chain[i].rotation == message.transformations[i].rotation); - CHECK(chain[i].vector.x == Catch::Approx(message.transformations[i].vector.x)); - CHECK(chain[i].vector.y == Catch::Approx(message.transformations[i].vector.y)); - CHECK(chain[i].vector.z == Catch::Approx(message.transformations[i].vector.z)); - CHECK(chain[i].start == Catch::Approx(message.transformations[i].start).margin(1e-4)); - CHECK(chain[i].increment == Catch::Approx(message.transformations[i].increment).margin(1e-6)); + const auto &got = chain[i]; + const auto &want = message.transformations[i]; + CHECK(got.GetName() == want.GetName()); + CHECK(got.IsRotation() == want.IsRotation()); + CHECK(got.GetUnits() == want.GetUnits()); + CHECK(got.GetDependsOn() == want.GetDependsOn()); + CHECK(got.GetVector().x == Catch::Approx(want.GetVector().x)); + CHECK(got.GetVector().y == Catch::Approx(want.GetVector().y)); + CHECK(got.GetVector().z == Catch::Approx(want.GetVector().z)); + CHECK(got.GetOffset().x == Catch::Approx(want.GetOffset().x)); + CHECK(got.IsConstant() == want.IsConstant()); + REQUIRE(got.GetValues().size() == want.GetValues().size()); + for (size_t j = 0; j < got.GetValues().size(); j++) + CHECK(got.GetValues()[j] == Catch::Approx(want.GetValues()[j]).margin(1e-4)); } + + // The base of the chain names no parent. + CHECK(chain[0].GetDependsOn().empty()); + CHECK(!chain[1].IsConstant()); + CHECK(chain[2].IsConstant()); } diff --git a/tests/JFJochReaderTest.cpp b/tests/JFJochReaderTest.cpp index 9f41085d..69d2bca3 100644 --- a/tests/JFJochReaderTest.cpp +++ b/tests/JFJochReaderTest.cpp @@ -2925,4 +2925,73 @@ TEST_CASE("JFJochReader_Snapshots", "[HDF5][Full]") { remove("test_snap_proc_master.h5"); REQUIRE(H5Fget_obj_count(H5F_OBJ_ALL, H5F_OBJ_ALL) == 0); -} \ No newline at end of file +} +// The chain may be sent in the END message or left to the writer to build. Both must produce the +// same file - the sent one is written verbatim, which is what will later allow measured positions to +// be reported, and the built one is what a producer that does not send it gets. +TEST_CASE("JFJochReader_TransformationChain_SentAndBuilt", "[HDF5][Full]") { + DiffractionExperiment x(DetJF(1)); + x.ImagesPerTrigger(5).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.Goniometer(GoniometerAxis("omega", 95, 0.1f, Coord(0,-1,0), {})); + x.Smargon(SmargonPosition{.phi_deg = -7.25f, .chi_deg = 12.5f}); + + RegisterHDF5Filter(); + std::vector image(x.GetPixelsNum(), 0); + + const auto write = [&](const std::string &prefix, bool send_chain) { + DiffractionExperiment local = x; + local.FilePrefix(prefix); + StartMessage start_message; + local.FillMessage(start_message); + + FileWriter file_set(start_message); + DataMessage message{}; + for (int i = 0; i < local.GetImageNum(); i++) { + message.image = CompressedImage(image, local.GetXPixelsNum(), local.GetYPixelsNum()); + message.number = i; + REQUIRE_NOTHROW(file_set.WriteHDF5(message)); + } + + EndMessage end_message; + end_message.max_image_number = local.GetImageNum(); + if (send_chain) + end_message.transformations = local.BuildTransformationChain(local.GetImageNum()); + file_set.WriteHDF5(end_message); + file_set.Finalize(); + }; + + write("test_chain_built", false); + write("test_chain_sent", true); + + const auto read = [](const std::string &prefix) { + JFJochHDF5Reader reader; + reader.ReadFile(prefix + "_master.h5"); + return reader.GetDataset()->experiment; + }; + + const auto built = read("test_chain_built"); + const auto sent = read("test_chain_sent"); + + REQUIRE(built.GetGoniometer().has_value()); + REQUIRE(sent.GetGoniometer().has_value()); + CHECK(sent.GetGoniometer()->GetName() == built.GetGoniometer()->GetName()); + CHECK(sent.GetGoniometer()->GetStart_deg() + == Catch::Approx(built.GetGoniometer()->GetStart_deg()).margin(1e-3)); + CHECK(sent.GetGoniometer()->GetIncrement_deg() + == Catch::Approx(built.GetGoniometer()->GetIncrement_deg()).margin(1e-4)); + + // chi/phi survive both routes, which they did not before they became ordinary axes. + REQUIRE(built.GetDatasetSettings().GetSmargonPosition().has_value()); + REQUIRE(sent.GetDatasetSettings().GetSmargonPosition().has_value()); + CHECK(sent.GetDatasetSettings().GetSmargonPosition()->chi_deg + == Catch::Approx(12.5f).margin(1e-3)); + CHECK(sent.GetDatasetSettings().GetSmargonPosition()->phi_deg + == Catch::Approx(-7.25f).margin(1e-3)); + + remove("test_chain_built_master.h5"); + remove("test_chain_sent_master.h5"); + REQUIRE(H5Fget_obj_count(H5F_OBJ_ALL, H5F_OBJ_ALL) == 0); +} diff --git a/writer/HDF5NXmx.cpp b/writer/HDF5NXmx.cpp index fde0c1f6..fef8391c 100644 --- a/writer/HDF5NXmx.cpp +++ b/writer/HDF5NXmx.cpp @@ -795,6 +795,34 @@ void NXmx::Sample(const StartMessage &start, const EndMessage &end) { SaveScalar(grid_scan_group, "step_y", start.grid_scan->GetGridStepY_um() * 1e-6)->Units("m"); } + // A producer that sent the chain already gets it written verbatim - no angle is recomputed here, + // which is what makes it possible to report positions that were measured rather than commanded. + // Otherwise the same chain is built from the start message, so nothing is lost by not sending it. + if (!end.transformations.empty()) { + HDF5Group transformations(group, "transformations"); + transformations.NXClass("NXtransformations"); + hdf5_file->HardLink("/entry/sample/transformations","/entry/sample/goniometer"); + + const std::string base = "/entry/sample/transformations/"; + for (const auto &axis: end.transformations) { + const std::string parent = axis.GetDependsOn().empty() ? "." : base + axis.GetDependsOn(); + const std::vector offset{axis.GetOffset().x, axis.GetOffset().y, axis.GetOffset().z}; + const std::vector vector{axis.GetVector().x, axis.GetVector().y, axis.GetVector().z}; + const std::string type = axis.IsRotation() ? "rotation" : "translation"; + + auto written = axis.IsConstant() + ? SaveScalar(transformations, axis.GetName(), + axis.GetValues().empty() ? 0.0f : axis.GetValues().front()) + : SaveVector(transformations, axis.GetName(), axis.GetValues()); + written->Transformation(axis.GetUnits(), parent, + axis.GetEquipment(), axis.GetEquipmentComponent(), + type, vector, offset, ""); + depends_on = base + axis.GetName(); + } + group.SaveScalar("depends_on", depends_on); + return; + } + if (write_goniometer || write_grid_scan || start.smargon_position) { HDF5Group transformations(group, "transformations"); transformations.NXClass("NXtransformations");