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:
@@ -102,6 +102,8 @@ ADD_LIBRARY(JFJochCommon STATIC
|
||||
JFJochMessages.h
|
||||
GoniometerAxis.cpp
|
||||
GoniometerAxis.h
|
||||
DetectorTransformation.cpp
|
||||
DetectorTransformation.h
|
||||
CompressedImage.cpp
|
||||
CompressedImage.h
|
||||
Reflection.h
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user