From b0e315e73cef301d9a55c2ee07743cedf8edc53b Mon Sep 17 00:00:00 2001
From: Filip Leonarski
Date: Fri, 31 Jul 2026 13:10:58 +0200
Subject: [PATCH] Bragg prediction: derive the lattice walk from the cell, and
expose it in the API
Follow-up to making max_hkl a setting: it is now an optional, and unset means "take
it from this crystal". The predictor keeps only |q| <= 1/d_min and h = a.q for the
real-space axis a, so |h| <= a/d_min exactly - and likewise |k| <= b/d_min and
|l| <= c/d_min. max(a,b,c)/d_min therefore bounds all three at once: nothing that
could be predicted lies outside it, and nothing inside it is reached by a shorter
axis. It applies to rotation and stills alike, both going through the one place the
prediction settings are built.
Offline (rugnux, viewer) the default is unset, so every crystal gets its own range;
--max-hkl overrides it. Online the broker holds a concrete number, because the cost
is the cube of it per image and a live acquisition should not have its frame rate
decided by whichever sample is mounted: max_hkl joins bragg_integration_settings in
the OpenAPI with a default of 100, so an omitted field arrives as that default (the
generated model carries it) rather than as "derive it", and the frontend exposes it
next to the integration model.
Measured against a fixed 100 on six rotation crystals: three are bit-identical, two
were being truncated and recover 419k and 5.8k observations with the high-shell
CC1/2 going 15.1 -> 25.8% and 52.1 -> 55.3%, and the space group is unchanged 6/6.
It reproduces a fixed 200 exactly, which is the bound being tight rather than merely
safe.
The sixth is worth recording: a 149/83/226 A cell derives 227, and because a single
scalar has to cover the longest axis the cube is ~16x what a per-axis box would be -
22% wall clock, for a net 22 observations out of 364k (the per-frame 65536-reflection
cap re-selects at the margin when more candidates are offered) and identical CC1/2,
ISa and space group. Per-axis limits would remove that; the predictors already map a
thread index to h, k and l separately.
Co-Authored-By: Claude Opus 5 (1M context)
---
broker/OpenAPIConvert.cpp | 7 +++
.../gen/model/Bragg_integration_settings.cpp | 48 +++++++++++++++++++
broker/gen/model/Bragg_integration_settings.h | 9 ++++
broker/jfjoch_api.yaml | 15 ++++++
broker/redoc-static.html | 18 +++++--
common/BraggIntegrationSettings.cpp | 14 +++---
common/BraggIntegrationSettings.h | 14 +++---
docs/CHANGELOG.md | 2 +-
docs/RUGNUX.md | 2 +-
.../docs/BraggIntegrationSettings.md | 1 +
frontend/src/client/types.gen.ts | 13 +++++
frontend/src/client/zod.gen.ts | 3 +-
.../components/BraggIntegrationSettings.tsx | 30 +++++++++++-
image_analysis/IndexAndRefine.cpp | 29 ++++++++++-
rugnux/RugnuxCommandLine.cpp | 4 +-
rugnux/rugnux_cli.cpp | 4 +-
16 files changed, 186 insertions(+), 27 deletions(-)
diff --git a/broker/OpenAPIConvert.cpp b/broker/OpenAPIConvert.cpp
index 3d6378e0..f41a145d 100644
--- a/broker/OpenAPIConvert.cpp
+++ b/broker/OpenAPIConvert.cpp
@@ -1073,6 +1073,11 @@ BraggIntegrationSettings Convert(const org::openapitools::server::model::Bragg_i
default:
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "Unknown integration model");
}
+ // Always a concrete number online, never "derive it from the crystal": the generated model holds
+ // the schema's default when the request omits the field, so an absent max_hkl arrives here as that
+ // default rather than as an absent value. Deriving per crystal would make a live acquisition's
+ // per-image cost depend on whichever sample is mounted.
+ ret.MaxHKL(input.getMaxHkl());
return ret;
}
@@ -1091,6 +1096,8 @@ org::openapitools::server::model::Bragg_integration_settings Convert(const Bragg
break;
}
ret.setIntegrationModel(tmp);
+ if (const auto max_hkl = input.GetMaxHKL())
+ ret.setMaxHkl(*max_hkl);
return ret;
}
diff --git a/broker/gen/model/Bragg_integration_settings.cpp b/broker/gen/model/Bragg_integration_settings.cpp
index 8b1328bf..30ab9d26 100644
--- a/broker/gen/model/Bragg_integration_settings.cpp
+++ b/broker/gen/model/Bragg_integration_settings.cpp
@@ -21,6 +21,8 @@ namespace org::openapitools::server::model
Bragg_integration_settings::Bragg_integration_settings()
{
+ m_Max_hkl = 100;
+ m_Max_hklIsSet = false;
}
@@ -43,7 +45,26 @@ bool Bragg_integration_settings::validate(std::stringstream& msg, const std::str
bool success = true;
const std::string _pathPrefix = pathPrefix.empty() ? "Bragg_integration_settings" : pathPrefix;
+
+ if (maxHklIsSet())
+ {
+ const int32_t& value = m_Max_hkl;
+ const std::string currentValuePath = _pathPrefix + ".maxHkl";
+
+ if (value < 1)
+ {
+ success = false;
+ msg << currentValuePath << ": must be greater than or equal to 1;";
+ }
+ if (value > 511)
+ {
+ success = false;
+ msg << currentValuePath << ": must be less than or equal to 511;";
+ }
+
+ }
+
return success;
}
@@ -53,8 +74,11 @@ bool Bragg_integration_settings::operator==(const Bragg_integration_settings& rh
(getIntegrationModel() == rhs.getIntegrationModel())
+ &&
+ ((!maxHklIsSet() && !rhs.maxHklIsSet()) || (maxHklIsSet() && rhs.maxHklIsSet() && getMaxHkl() == rhs.getMaxHkl()))
+
;
}
@@ -67,12 +91,19 @@ void to_json(nlohmann::json& j, const Bragg_integration_settings& o)
{
j = nlohmann::json::object();
j["integration_model"] = o.m_Integration_model;
+ if(o.maxHklIsSet())
+ j["max_hkl"] = o.m_Max_hkl;
}
void from_json(const nlohmann::json& j, Bragg_integration_settings& o)
{
j.at("integration_model").get_to(o.m_Integration_model);
+ if(j.find("max_hkl") != j.end())
+ {
+ j.at("max_hkl").get_to(o.m_Max_hkl);
+ o.m_Max_hklIsSet = true;
+ }
}
@@ -84,6 +115,23 @@ void Bragg_integration_settings::setIntegrationModel(org::openapitools::server::
{
m_Integration_model = value;
}
+int32_t Bragg_integration_settings::getMaxHkl() const
+{
+ return m_Max_hkl;
+}
+void Bragg_integration_settings::setMaxHkl(int32_t const value)
+{
+ m_Max_hkl = value;
+ m_Max_hklIsSet = true;
+}
+bool Bragg_integration_settings::maxHklIsSet() const
+{
+ return m_Max_hklIsSet;
+}
+void Bragg_integration_settings::unsetMax_hkl()
+{
+ m_Max_hklIsSet = false;
+}
} // namespace org::openapitools::server::model
diff --git a/broker/gen/model/Bragg_integration_settings.h b/broker/gen/model/Bragg_integration_settings.h
index b82dc5c0..53f9f678 100644
--- a/broker/gen/model/Bragg_integration_settings.h
+++ b/broker/gen/model/Bragg_integration_settings.h
@@ -63,12 +63,21 @@ public:
///
org::openapitools::server::model::Integration_model getIntegrationModel() const;
void setIntegrationModel(org::openapitools::server::model::Integration_model const& value);
+ ///
+ /// How far the Bragg predictor walks the lattice: reflections with |h|,|k|,|l| above this are never predicted. An axis is truncated once a/d_min exceeds it, so a long axis - or a short one taken to high resolution - loses its outermost reflections. The cost grows as the cube (2n+1)^3 of candidates per image, which is why online keeps a fixed, predictable value instead of taking it from whichever crystal is mounted. Omitting the field selects the default above; it is never interpreted as \"choose per crystal\". The offline tools (rugnux, viewer) do derive it from the refined cell when it is left unset there, but that is their own default and is not reachable through this API.
+ ///
+ int32_t getMaxHkl() const;
+ void setMaxHkl(int32_t const value);
+ bool maxHklIsSet() const;
+ void unsetMax_hkl();
friend void to_json(nlohmann::json& j, const Bragg_integration_settings& o);
friend void from_json(const nlohmann::json& j, Bragg_integration_settings& o);
protected:
org::openapitools::server::model::Integration_model m_Integration_model;
+ int32_t m_Max_hkl;
+ bool m_Max_hklIsSet;
};
diff --git a/broker/jfjoch_api.yaml b/broker/jfjoch_api.yaml
index e4c174f3..f880c728 100644
--- a/broker/jfjoch_api.yaml
+++ b/broker/jfjoch_api.yaml
@@ -2348,6 +2348,21 @@ components:
properties:
integration_model:
$ref: '#/components/schemas/integration_model'
+ max_hkl:
+ type: integer
+ minimum: 1
+ maximum: 511
+ default: 100
+ description: |
+ How far the Bragg predictor walks the lattice: reflections with |h|,|k|,|l| above this are
+ never predicted. An axis is truncated once a/d_min exceeds it, so a long axis - or a short
+ one taken to high resolution - loses its outermost reflections. The cost grows as the cube
+ (2n+1)^3 of candidates per image, which is why online keeps a fixed, predictable value
+ instead of taking it from whichever crystal is mounted.
+
+ Omitting the field selects the default above; it is never interpreted as "choose per
+ crystal". The offline tools (rugnux, viewer) do derive it from the refined cell when it is
+ left unset there, but that is their own default and is not reachable through this API.
jfjoch_settings:
type: object
required:
diff --git a/broker/redoc-static.html b/broker/redoc-static.html
index 434419cc..973da47c 100644
--- a/broker/redoc-static.html
+++ b/broker/redoc-static.html
@@ -609,19 +609,27 @@ If set to true, the thread pool will block until a thread is available.
Responses
Test Jungfraujoch system
http://localhost:5232/config/indexing
Response samples
200
Content type
application/json
{
"algorithm": "FFBIDX",
"fft_max_unit_cell_A": 250,
"fft_min_unit_cell_A": 10,
"fft_high_resolution_A": 2,
"fft_num_vectors": 16384,
"tolerance": 0.5,
"thread_count": 1,
"geom_refinement_algorithm": "BeamCenter",
"unit_cell_dist_tolerance": 0.05,
"viable_cell_min_spots": 10,
"index_ice_rings": false,
"rotation_indexing": false,
"rotation_indexing_min_angular_range_deg": 20,
"rotation_indexing_angular_stride_deg": 0.5,
"blocking": true
}
Change Bragg integration settings
This can only be done when detector is Idle, Error or Inactive states.
-
Request Body schema: application/json
integration_model
required
string (integration_model)
Default: "ProfileGaussian"
Enum:"ProfileGaussian""ProfileEmpirical""BoxSum"
Bragg spot integration model.
+
Request Body schema: application/json
integration_model
required
string (integration_model)
Default: "ProfileGaussian"
Enum:"ProfileGaussian""ProfileEmpirical""BoxSum"
Bragg spot integration model.
ProfileGaussian - profile fit with a measured-width Gaussian (Kabsch-style), the default; more
accurate intensities than box summation.
ProfileEmpirical - profile fit with a per-resolution-shell empirical profile learned from strong spots.
BoxSum - classical uniform box summation minus a ring-mean background; the simpler, faster fallback.
+
max_hkl
integer [ 1 .. 511 ]
Default: 100
How far the Bragg predictor walks the lattice: reflections with |h|,|k|,|l| above this are
+never predicted. An axis is truncated once a/d_min exceeds it, so a long axis - or a short
+one taken to high resolution - loses its outermost reflections. The cost grows as the cube
+(2n+1)^3 of candidates per image, which is why online keeps a fixed, predictable value
+instead of taking it from whichever crystal is mounted.
+
Omitting the field selects the default above; it is never interpreted as "choose per
+crystal". The offline tools (rugnux, viewer) do derive it from the refined cell when it is
+left unset there, but that is their own default and is not reachable through this API.
Responses
Test Jungfraujoch system
-
http://localhost:5232/config/bragg_integration
Request samples
Payload
Content type
application/json
{
"integration_model": "ProfileGaussian"
}
Response samples
500
Content type
application/json
{
"msg": "Detector in wrong state",
"reason": "WrongDAQState"
}
Get Bragg integration configuration
Can be done anytime
+
http://localhost:5232/config/bragg_integration
Request samples
Payload
Content type
application/json
{
"integration_model": "ProfileGaussian",
"max_hkl": 100
}
Response samples
500
Content type
application/json
{
"msg": "Detector in wrong state",
"reason": "WrongDAQState"
}
Get Bragg integration configuration
Can be done anytime
Responses
Test Jungfraujoch system
-
http://localhost:5232/config/bragg_integration
Response samples
200
Content type
application/json
{
"integration_model": "ProfileGaussian"
}
Change file writer settings
This can only be done when detector is Idle, Error or Inactive states.
+
http://localhost:5232/config/bragg_integration
Response samples
200
Content type
application/json
{
"integration_model": "ProfileGaussian",
"max_hkl": 100
}
Change file writer settings
This can only be done when detector is Idle, Error or Inactive states.
Request Body schema: application/json
overwrite
boolean
Default: false
Inform jfjoch_write to overwrite existing files. Otherwise files would be saved with .h5.{timestamp}.tmp suffix.
NoFileWritten - no files are written at all
NXmxOnlyData - only data files are written, no master file
@@ -836,7 +844,7 @@ This can only be done when detector is Idle, Error or