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
+2
View File
@@ -102,6 +102,8 @@ ADD_LIBRARY(JFJochCommon STATIC
JFJochMessages.h
GoniometerAxis.cpp
GoniometerAxis.h
DetectorTransformation.cpp
DetectorTransformation.h
CompressedImage.cpp
CompressedImage.h
Reflection.h
+106
View File
@@ -0,0 +1,106 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <utility>
#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<float> &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<float> &DetectorTransformation::GetValues() const {
return values;
}
bool DetectorTransformation::IsRotation() const {
return type == TransformationType::Rotation;
}
bool DetectorTransformation::IsConstant() const {
return values.size() <= 1;
}
+63
View File
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <string>
#include <vector>
#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<DetectorTransformation> 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<float> 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<float> &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<float>& 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;
};
+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;
+3 -2
View File
@@ -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<TransformationAxis> 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<DetectorTransformation> BuildTransformationChain(int64_t image_num) const;
[[nodiscard]] DetectorType GetDetectorType() const;
[[nodiscard]] bool IsMaskPixelsWithoutG0() const;
+8 -21
View File
@@ -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<ROIConfig> rois;
std::optional<GridScanSettings> 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<TransformationAxis> transformations;
std::optional<GoniometerAxis> 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<DetectorTransformation> transformations;
uint64_t max_image_number; // Counting from 1, i.e. 0 = no images collected
std::optional<uint64_t> images_collected_count;
std::optional<uint64_t> images_sent_to_write_count;
+1 -7
View File
@@ -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 | |
+1 -3
View File
@@ -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.
+27 -17
View File
@@ -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<DetectorTransformation> &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<float> 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")
+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);
+35 -18
View File
@@ -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<uint8_t> 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());
}
+70 -1
View File
@@ -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);
}
}
// 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<uint16_t> 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);
}
+28
View File
@@ -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<double> offset{axis.GetOffset().x, axis.GetOffset().y, axis.GetOffset().z};
const std::vector<double> 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");