api: record the beam size at the sample, and write it where NXmx puts it

dataset_settings gains beam_size_x_um and beam_size_y_um, the horizontal and
vertical size of the X-ray beam where it meets the sample. They follow the same
route total_flux takes - OpenAPI, DatasetSettings, the CBOR start message, the
HDF5 master, and back out of a stored file - and nothing consumes them; this is
metadata a beamline can state and a downstream program can read.

NXmx puts this in the application definition rather than the base class: not
NXbeam's extent (rank 2, nP x 2, per scan point, always FWHM of a rectangular
aperture) but NXmx's own incident_beam_size, a recommended rank-1 two-element
array in the order x, y. Both are live and neither is deprecated, so the choice
matters; the MX definition wins in an MX file. Written as one array with a
units attribute of "m", like every other length in the master, so the settings
hold micrometres and FillMessage converts once.

The unit table of ReadLength_m becomes LengthUnitFactor so the array read can
share it: a master written elsewhere may state this in millimetres.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
This commit is contained in:
2026-09-02 09:59:34 +02:00
co-authored by Claude Opus 5
parent 7890149ad6
commit 8a04773d1d
20 changed files with 253 additions and 10 deletions
+4
View File
@@ -663,6 +663,10 @@ DatasetSettings Convert(const org::openapitools::server::model::Dataset_settings
if (input.totalFluxIsSet())
ret.TotalFlux(input.getTotalFlux());
if (input.beamSizeXUmIsSet())
ret.BeamSizeX_um(input.getBeamSizeXUm());
if (input.beamSizeYUmIsSet())
ret.BeamSizeY_um(input.getBeamSizeYUm());
if (input.transmissionIsSet())
ret.AttenuatorTransmission(input.getTransmission());
// Not alternatives: a grid scan is often collected at a given head position, so an axis and a
+86
View File
@@ -45,6 +45,10 @@ Dataset_settings::Dataset_settings()
m_Total_fluxIsSet = false;
m_Transmission = 0.0f;
m_TransmissionIsSet = false;
m_Beam_size_x_um = 0.0f;
m_Beam_size_x_umIsSet = false;
m_Beam_size_y_um = 0.0f;
m_Beam_size_y_umIsSet = false;
m_GoniometerIsSet = false;
m_Grid_scanIsSet = false;
m_Header_appendixIsSet = false;
@@ -237,6 +241,34 @@ bool Dataset_settings::validate(std::stringstream& msg, const std::string& pathP
}
}
if (beamSizeXUmIsSet())
{
const float& value = m_Beam_size_x_um;
const std::string currentValuePath = _pathPrefix + ".beamSizeXUm";
if (value < static_cast<float>(0.0))
{
success = false;
msg << currentValuePath << ": must be greater than or equal to 0.0;";
}
}
if (beamSizeYUmIsSet())
{
const float& value = m_Beam_size_y_um;
const std::string currentValuePath = _pathPrefix + ".beamSizeYUm";
if (value < static_cast<float>(0.0))
{
success = false;
msg << currentValuePath << ": must be greater than or equal to 0.0;";
}
}
if (dataReductionFactorSerialmxIsSet())
{
@@ -478,6 +510,12 @@ bool Dataset_settings::operator==(const Dataset_settings& rhs) const
((!transmissionIsSet() && !rhs.transmissionIsSet()) || (transmissionIsSet() && rhs.transmissionIsSet() && getTransmission() == rhs.getTransmission())) &&
((!beamSizeXUmIsSet() && !rhs.beamSizeXUmIsSet()) || (beamSizeXUmIsSet() && rhs.beamSizeXUmIsSet() && getBeamSizeXUm() == rhs.getBeamSizeXUm())) &&
((!beamSizeYUmIsSet() && !rhs.beamSizeYUmIsSet()) || (beamSizeYUmIsSet() && rhs.beamSizeYUmIsSet() && getBeamSizeYUm() == rhs.getBeamSizeYUm())) &&
((!goniometerIsSet() && !rhs.goniometerIsSet()) || (goniometerIsSet() && rhs.goniometerIsSet() && getGoniometer() == rhs.getGoniometer())) &&
@@ -587,6 +625,10 @@ void to_json(nlohmann::json& j, const Dataset_settings& o)
j["total_flux"] = o.m_Total_flux;
if(o.transmissionIsSet())
j["transmission"] = o.m_Transmission;
if(o.beamSizeXUmIsSet())
j["beam_size_x_um"] = o.m_Beam_size_x_um;
if(o.beamSizeYUmIsSet())
j["beam_size_y_um"] = o.m_Beam_size_y_um;
if(o.goniometerIsSet())
j["goniometer"] = o.m_Goniometer;
if(o.gridScanIsSet())
@@ -696,6 +738,16 @@ void from_json(const nlohmann::json& j, Dataset_settings& o)
j.at("transmission").get_to(o.m_Transmission);
o.m_TransmissionIsSet = true;
}
if(j.find("beam_size_x_um") != j.end())
{
j.at("beam_size_x_um").get_to(o.m_Beam_size_x_um);
o.m_Beam_size_x_umIsSet = true;
}
if(j.find("beam_size_y_um") != j.end())
{
j.at("beam_size_y_um").get_to(o.m_Beam_size_y_um);
o.m_Beam_size_y_umIsSet = true;
}
if(j.find("goniometer") != j.end())
{
j.at("goniometer").get_to(o.m_Goniometer);
@@ -1026,6 +1078,40 @@ void Dataset_settings::unsetTransmission()
{
m_TransmissionIsSet = false;
}
float Dataset_settings::getBeamSizeXUm() const
{
return m_Beam_size_x_um;
}
void Dataset_settings::setBeamSizeXUm(float const value)
{
m_Beam_size_x_um = value;
m_Beam_size_x_umIsSet = true;
}
bool Dataset_settings::beamSizeXUmIsSet() const
{
return m_Beam_size_x_umIsSet;
}
void Dataset_settings::unsetBeam_size_x_um()
{
m_Beam_size_x_umIsSet = false;
}
float Dataset_settings::getBeamSizeYUm() const
{
return m_Beam_size_y_um;
}
void Dataset_settings::setBeamSizeYUm(float const value)
{
m_Beam_size_y_um = value;
m_Beam_size_y_umIsSet = true;
}
bool Dataset_settings::beamSizeYUmIsSet() const
{
return m_Beam_size_y_umIsSet;
}
void Dataset_settings::unsetBeam_size_y_um()
{
m_Beam_size_y_umIsSet = false;
}
org::openapitools::server::model::Rotation_axis Dataset_settings::getGoniometer() const
{
return m_Goniometer;
+18
View File
@@ -155,6 +155,20 @@ public:
bool transmissionIsSet() const;
void unsetTransmission();
/// <summary>
/// First element of /entry/instrument/beam/incident_beam_size in NXmx Horizontal size of the X-ray beam where it meets the sample - the FWHM of a focused beam, the full width of a slit-defined one. [um]
/// </summary>
float getBeamSizeXUm() const;
void setBeamSizeXUm(float const value);
bool beamSizeXUmIsSet() const;
void unsetBeam_size_x_um();
/// <summary>
/// Second element of /entry/instrument/beam/incident_beam_size in NXmx Vertical size of the X-ray beam where it meets the sample. [um]
/// </summary>
float getBeamSizeYUm() const;
void setBeamSizeYUm(float const value);
bool beamSizeYUmIsSet() const;
void unsetBeam_size_y_um();
/// <summary>
///
/// </summary>
org::openapitools::server::model::Rotation_axis getGoniometer() const;
@@ -361,6 +375,10 @@ protected:
bool m_Total_fluxIsSet;
float m_Transmission;
bool m_TransmissionIsSet;
float m_Beam_size_x_um;
bool m_Beam_size_x_umIsSet;
float m_Beam_size_y_um;
bool m_Beam_size_y_umIsSet;
org::openapitools::server::model::Rotation_axis m_Goniometer;
bool m_GoniometerIsSet;
org::openapitools::server::model::Grid_scan m_Grid_scan;
+15
View File
@@ -476,6 +476,21 @@ components:
description: |
/entry/instrument/attenuator/attenuator_transmission
Transmission of attenuator (filter) [no units]
beam_size_x_um:
type: number
format: float
minimum: 0.0
description: |
First element of /entry/instrument/beam/incident_beam_size in NXmx
Horizontal size of the X-ray beam where it meets the sample - the FWHM of a focused beam,
the full width of a slit-defined one. [um]
beam_size_y_um:
type: number
format: float
minimum: 0.0
description: |
Second element of /entry/instrument/beam/incident_beam_size in NXmx
Vertical size of the X-ray beam where it meets the sample. [um]
goniometer:
$ref: "#/components/schemas/rotation_axis"
grid_scan:
File diff suppressed because one or more lines are too long
+26
View File
@@ -170,6 +170,24 @@ DatasetSettings &DatasetSettings::TotalFlux(const std::optional<float> &input) {
return *this;
}
DatasetSettings &DatasetSettings::BeamSizeX_um(const std::optional<float> &input) {
if (input) {
check_finite("Beam size X", input.value());
check_min("Beam size X", input.value(), 0.0);
}
beam_size_x_um = input;
return *this;
}
DatasetSettings &DatasetSettings::BeamSizeY_um(const std::optional<float> &input) {
if (input) {
check_finite("Beam size Y", input.value());
check_min("Beam size Y", input.value(), 0.0);
}
beam_size_y_um = input;
return *this;
}
DatasetSettings &DatasetSettings::Goniometer(const std::optional<GoniometerAxis> &input) {
goniometer = input;
return *this;
@@ -193,6 +211,14 @@ std::optional<float> DatasetSettings::GetTotalFlux() const {
return total_flux;
}
std::optional<float> DatasetSettings::GetBeamSizeX_um() const {
return beam_size_x_um;
}
std::optional<float> DatasetSettings::GetBeamSizeY_um() const {
return beam_size_y_um;
}
const std::optional<GoniometerAxis> &DatasetSettings::GetGoniometer() const {
return goniometer;
}
+7
View File
@@ -37,6 +37,9 @@ class DatasetSettings {
std::optional<float> total_flux;
std::optional<float> attenuator_transmission;
// Size of the beam where it meets the sample, x then y (NXmx incident_beam_size)
std::optional<float> beam_size_x_um;
std::optional<float> beam_size_y_um;
std::optional<float> ring_current_mA;
std::optional<GoniometerAxis> goniometer;
std::optional<GridScanSettings> grid_scan;
@@ -90,6 +93,8 @@ public:
DatasetSettings& SampleName(std::string input);
DatasetSettings& AttenuatorTransmission(const std::optional<float> &input);
DatasetSettings& TotalFlux(const std::optional<float> &input);
DatasetSettings& BeamSizeX_um(const std::optional<float> &input);
DatasetSettings& BeamSizeY_um(const std::optional<float> &input);
DatasetSettings& Goniometer(const std::optional<GoniometerAxis>& input);
DatasetSettings& GridScan(const std::optional<GridScanSettings>& input);
DatasetSettings& HeaderAppendix(const nlohmann::json& input);
@@ -121,6 +126,8 @@ public:
std::optional<float> GetAttenuatorTransmission() const;
std::optional<float> GetTotalFlux() const;
std::optional<float> GetBeamSizeX_um() const;
std::optional<float> GetBeamSizeY_um() const;
std::optional<GoniometerAxis> &Goniometer();
std::optional<GridScanSettings> &GridScan();
+24
View File
@@ -665,6 +665,12 @@ void DiffractionExperiment::FillMessage(StartMessage &message) const {
if (const auto bw = GetBandwidthFWHM())
message.incident_wavelength_spread = bw.value() * GetWavelength_A();
message.incident_energy = GetIncidentEnergy_keV() * 1e3f;
// NXmx incident_beam_size is a length like every other in this message, so micrometres in
// the settings become metres here.
if (const auto beam_size_x = GetBeamSizeX_um())
message.beam_size_x = beam_size_x.value() * 1e-6f;
if (const auto beam_size_y = GetBeamSizeY_um())
message.beam_size_y = beam_size_y.value() * 1e-6f;
message.image_size_x = GetXPixelsNum();
message.image_size_y = GetYPixelsNum();
message.mirror_y = IsDetectorMirroredY();
@@ -920,6 +926,16 @@ DiffractionExperiment &DiffractionExperiment::TotalFlux(const std::optional<floa
return *this;
}
DiffractionExperiment &DiffractionExperiment::BeamSizeX_um(const std::optional<float> &input) {
dataset.BeamSizeX_um(input);
return *this;
}
DiffractionExperiment &DiffractionExperiment::BeamSizeY_um(const std::optional<float> &input) {
dataset.BeamSizeY_um(input);
return *this;
}
std::optional<float> DiffractionExperiment::GetAttenuatorTransmission() const {
return dataset.GetAttenuatorTransmission();
}
@@ -928,6 +944,14 @@ std::optional<float> DiffractionExperiment::GetTotalFlux() const {
return dataset.GetTotalFlux();
}
std::optional<float> DiffractionExperiment::GetBeamSizeX_um() const {
return dataset.GetBeamSizeX_um();
}
std::optional<float> DiffractionExperiment::GetBeamSizeY_um() const {
return dataset.GetBeamSizeY_um();
}
DiffractionExperiment &DiffractionExperiment::Goniometer(const std::optional<GoniometerAxis> &input) {
dataset.Goniometer(input);
return *this;
+4
View File
@@ -144,6 +144,8 @@ public:
DiffractionExperiment& SampleName(const std::string &input);
DiffractionExperiment& AttenuatorTransmission(const std::optional<float> &input);
DiffractionExperiment& TotalFlux(const std::optional<float> &input);
DiffractionExperiment& BeamSizeX_um(const std::optional<float> &input);
DiffractionExperiment& BeamSizeY_um(const std::optional<float> &input);
DiffractionExperiment& Goniometer(const std::optional<GoniometerAxis> &input);
DiffractionExperiment& Smargon(const std::optional<SmargonPosition> &input);
DiffractionExperiment& HeaderAppendix(const nlohmann::json& input);
@@ -334,6 +336,8 @@ public:
std::optional<float> GetAttenuatorTransmission() const;
std::optional<float> GetTotalFlux() const;
std::optional<float> GetBeamSizeX_um() const;
std::optional<float> GetBeamSizeY_um() const;
std::optional<GoniometerAxis> GetGoniometer() const;
std::optional<GridScanSettings> GetGridScan() const;
+3
View File
@@ -244,6 +244,9 @@ struct StartMessage {
float incident_energy;
float incident_wavelength;
std::optional<float> incident_wavelength_spread; // NXmx incident_wavelength_spread: FWHM of dlambda (Angstrom)
// NXmx incident_beam_size: size of the beam where it meets the sample, x then y (m)
std::optional<float> beam_size_x;
std::optional<float> beam_size_y;
float frame_time;
float count_time;
+2
View File
@@ -33,6 +33,8 @@ There are minor differences at the moment:
| incident_energy | float | X-ray energy \[eV\] | X |
| incident_wavelength | float | X-ray wavelength \[Angstrom\] | X |
| incident_wavelength_spread | float (optional) | FWHM of the X-ray wavelength distribution \[Angstrom\] (NXmx incident_wavelength_spread); omitted when the beam is monochromatic | |
| beam_size_x | float (optional) | Horizontal size of the X-ray beam at the sample \[m\] (first element of NXmx incident_beam_size) | |
| beam_size_y | float (optional) | Vertical size of the X-ray beam at the sample \[m\] (second element of NXmx incident_beam_size) | |
| frame_time | float | Frame time, if multiple frames per trigger \[s\] | X |
| count_time | float | Exposure time \[s\] | X |
| saturation_value | int64 | Maximum valid sample value | X |
+1
View File
@@ -24,6 +24,7 @@
* `jfjoch_writer` writes `direct_beam_x`/`direct_beam_y` in the HDF5 master - where the undeflected beam lands - beside the `beam_center_x`/`beam_center_y` PONI.
* `jfjoch_writer` writes `/entry/MX/peakCountUnfiltered` in the HDF5 master beside the other per-image spot counts, instead of only in the data files.
* `jfjoch_broker` sends `direct_beam_x`/`direct_beam_y` on the CBOR start message.
* `dataset_settings` takes `beam_size_x_um`/`beam_size_y_um`, the size of the X-ray beam at the sample, and `jfjoch_writer` writes them as `incident_beam_size` in the HDF5 master.
* `jfjoch_viewer` reads PILATUS miniCBF sweeps natively, and draws grid scan cells in the proportion of the scan steps.
* The rugnux manual is reorganised into task pages with a run overview, and gains worked phenix / REFMAC5 / POINTLESS-AIMLESS / careless examples.
* `jfjoch_viewer` labels the merge-statistics plot over the range the axis is drawn on, so the CC1/2 curve is no longer read against tick labels covering only part of it.
+1
View File
@@ -154,6 +154,7 @@ File-level HDF5 attributes `file_name`, `file_time`, `HDF5_Version` are also set
| `incident_wavelength` | NXmx | angstrom |
| `incident_wavelength_spread` | NXmx | angstrom (only if polychromatic) |
| `total_flux` | NXmx | Hz |
| `incident_beam_size` | NXmx | m (two elements, x then y; written only when both beam sizes are given) |
### `/entry/instrument/attenuator` (NXattenuator)
@@ -1276,6 +1276,10 @@ namespace {
message.incident_wavelength = GetCBORFloat(value);
else if (key == "incident_wavelength_spread")
message.incident_wavelength_spread = GetCBORFloat(value);
else if (key == "beam_size_x")
message.beam_size_x = GetCBORFloat(value);
else if (key == "beam_size_y")
message.beam_size_y = GetCBORFloat(value);
else if (key == "frame_time")
message.frame_time = GetCBORFloat(value);
else if (key == "count_time")
@@ -702,6 +702,8 @@ void CBORStream2Serializer::SerializeSequenceStart(const StartMessage& message)
CBOR_ENC(mapEncoder, "incident_energy", message.incident_energy);
CBOR_ENC(mapEncoder, "incident_wavelength", message.incident_wavelength);
CBOR_ENC(mapEncoder, "incident_wavelength_spread", message.incident_wavelength_spread);
CBOR_ENC(mapEncoder, "beam_size_x", message.beam_size_x);
CBOR_ENC(mapEncoder, "beam_size_y", message.beam_size_y);
CBOR_ENC(mapEncoder, "frame_time", message.frame_time);
CBOR_ENC(mapEncoder, "count_time", message.count_time);
+13
View File
@@ -228,6 +228,19 @@ export type dataset_settings = {
*
*/
transmission?: number;
/**
* First element of /entry/instrument/beam/incident_beam_size in NXmx
* Horizontal size of the X-ray beam where it meets the sample - the FWHM of a focused beam,
* the full width of a slit-defined one. [um]
*
*/
beam_size_x_um?: number;
/**
* Second element of /entry/instrument/beam/incident_beam_size in NXmx
* Vertical size of the X-ray beam where it meets the sample. [um]
*
*/
beam_size_y_um?: number;
goniometer?: rotation_axis;
grid_scan?: grid_scan;
/**
+2
View File
@@ -125,6 +125,8 @@ export const zDatasetSettings = z.object({
]).optional().default('bslz4'),
total_flux: z.number().optional(),
transmission: z.number().gte(0).lte(1).optional(),
beam_size_x_um: z.number().gte(0).optional(),
beam_size_y_um: z.number().gte(0).optional(),
goniometer: zRotationAxis.optional(),
grid_scan: zGridScan.optional(),
header_appendix: z.unknown().optional(),
+23 -8
View File
@@ -197,23 +197,25 @@ std::string ResolveRelativeToMaster(const std::string &directory,
// silent factor of a thousand, so the unit is read rather than assumed. An undeclared unit means
// metres - what every DECTRIS master and everything this system writes means by one. An unknown
// unit is refused rather than guessed at, for the same reason.
float ReadLength_m(HDF5Object &file, const std::string &name) {
HDF5DataSet dataset(file, name);
const float value = dataset.ReadScalar<float>();
float LengthUnitFactor(HDF5DataSet &dataset, const std::string &name) {
if (!dataset.AttrExists("units"))
return value;
return 1.0f;
const std::string units = dataset.ReadAttrStr("units");
if (units == "m")
return value;
return 1.0f;
if (units == "mm")
return value * 1e-3f;
return 1e-3f;
if (units == "um")
return value * 1e-6f;
return 1e-6f;
throw JFJochException(JFJochExceptionCategory::HDF5, name + ": unknown length unit " + units);
}
float ReadLength_m(HDF5Object &file, const std::string &name) {
HDF5DataSet dataset(file, name);
return dataset.ReadScalar<float>() * LengthUnitFactor(dataset, name);
}
// The same value under different names. DECTRIS Eiger firmware 1.x writes detector_distance where
// NXmx says distance, and a file from that era is still what a repository hands you; a Diamond
// master puts the distance one level up, in NXinstrument rather than in NXdetector. The NXmx
@@ -926,6 +928,19 @@ HDF5MetadataSource::OpenResult HDF5MetadataSource::Open(const std::string &filen
total_flux.reset(); // negative value is an "unknown flux" sentinel; treat as absent
dataset->experiment.TotalFlux(total_flux);
// NXmx incident_beam_size is one two-element array in the order x, y; the settings hold
// it as two lengths in micrometres.
if (master_file->Exists("/entry/instrument/beam/incident_beam_size")) {
HDF5DataSet beam_size(*master_file, "/entry/instrument/beam/incident_beam_size");
std::vector<float> size;
beam_size.ReadVector(size);
if (size.size() == 2) {
const float to_um = LengthUnitFactor(beam_size, "incident_beam_size") * 1e6f;
dataset->experiment.BeamSizeX_um(size[0] * to_um);
dataset->experiment.BeamSizeY_um(size[1] * to_um);
}
}
if (master_file->Exists("/entry/azint") && master_file->Exists("/entry/azint/bin_to_q")) {
HDF5DataSet bin_to_q_dataset(*master_file, "/entry/azint/bin_to_q");
HDF5DataSpace bin_to_q_dataspace(bin_to_q_dataset);
+6
View File
@@ -28,6 +28,8 @@ TEST_CASE("CBORSerialize_Start", "[CBOR]") {
.countrate_correction_enabled = true,
.incident_energy = 12400,
.incident_wavelength = 0.988,
.beam_size_x = 8e-5,
.beam_size_y = 3e-5,
.frame_time = 0.0001,
.count_time = 0.000098,
.saturation_value = 65534,
@@ -145,6 +147,10 @@ TEST_CASE("CBORSerialize_Start", "[CBOR]") {
CHECK(output_message.unit_cell->beta == message.unit_cell->beta);
CHECK(output_message.unit_cell->gamma == message.unit_cell->gamma);
REQUIRE(output_message.beam_size_x);
CHECK(output_message.beam_size_x.value() == message.beam_size_x.value());
REQUIRE(output_message.beam_size_y);
CHECK(output_message.beam_size_y.value() == message.beam_size_y.value());
REQUIRE(output_message.total_flux);
CHECK(output_message.total_flux.value() == message.total_flux.value());
REQUIRE(output_message.attenuator_transmission);
+5
View File
@@ -627,6 +627,11 @@ void NXmx::Beam(const StartMessage &start) {
SaveScalar(group, "incident_wavelength_spread", start.incident_wavelength_spread.value())->Units("angstrom");
if (start.total_flux)
SaveScalar(group, "total_flux", start.total_flux.value())->Units("Hz");
// NXmx asks for the beam size as one two-element array in the order x, y, so it is written
// only when both have been given.
if (start.beam_size_x && start.beam_size_y)
group.SaveVector("incident_beam_size",
std::vector<float>{start.beam_size_x.value(), start.beam_size_y.value()})->Units("m");
}
void NXmx::Fluorescence(const StartMessage &start) {