grid scan: an unstated beam is reported as the grid step, not as zero
Build Packages / build:windows:nocuda (push) Failing after 9m18s
Build Packages / build:viewer-tgz:cpu (push) Successful in 14m24s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 13m14s
Build Packages / build:viewer-tgz:cuda (push) Successful in 15m15s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 6m50s
Build Packages / build:rpm (rocky8_nocuda) (push) Failing after 3m42s
Build Packages / build:rpm (rocky9_nocuda) (push) Failing after 3m51s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Failing after 3m35s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Failing after 3m5s
Build Packages / build:rpm (rocky8_sls9) (push) Failing after 3m44s
Build Packages / build:rpm (rocky9_sls9) (push) Failing after 4m3s
Build Packages / build:rpm (rocky8) (push) Failing after 4m2s
Build Packages / build:rpm (rocky9) (push) Failing after 3m55s
Build Packages / build:rpm (ubuntu2204) (push) Failing after 3m54s
Build Packages / build:rpm (ubuntu2404) (push) Failing after 3m55s
Build Packages / Generate python client (push) Successful in 14s
Build Packages / build:rugnux:windows (push) Successful in 17m49s
Build Packages / Build documentation (push) Successful in 48s
Build Packages / Create release (push) Skipped
Build Packages / build:windows:cuda (push) Successful in 20m19s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 6m48s
Build Packages / XDS test (durin plugin) (push) Successful in 7m43s
Build Packages / XDS test (neggia plugin) (push) Successful in 6m54s
Build Packages / DIALS test (push) Successful in 12m41s
Build Packages / Unit tests (push) Successful in 59m43s

A raster is stepped at about the beam size, so the step is the best proxy there is
when neither --beam-size nor the file's incident_beam_size says anything. Zero is the
worse answer: the reported crystal extents still contain a whole beam, and a zero
tells a consumer deconvolving them that they are already exact.

The substitution is in AnalyzeGridScan, so it holds for the broker and for rugnux
alike. The extents themselves do not move - they are measured either way, and the
test pins that. BEAM_SIZE_SOURCE in the raster report gains GRID_STEP, so a reader
can still tell a measured beam from a stood-in one, which matters because removing an
anisotropic beam is a covariance subtraction and a wrong one rotates the crystal axis.

The comment at the rugnux call site had argued for the old behaviour in as many
words; it now describes what the code does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
This commit is contained in:
leonarski_fandClaude Opus 5 committed 2026-09-08 09:11:26 +02:00
1 parent e381d2fd50
commit aa5a31b345
6 files changed
+46 -12

No files matched your search

+1
View File
@@ -15,6 +15,7 @@
* The stream and the written file record which analysis produced them, as `analysis_mode` in the CBOR start message and `/entry/MX/analysis_mode` in the HDF5 master.
* `rugnux --mode` accepts `mx_rotation` and `mx_stills` beside `mx`.
* `rugnux --mode raster` reports the crystals in a stored grid scan - grid position, nearest image, measured size and orientation, score, ice score and resolution - as `<prefix>_raster_report.txt` and `<prefix>_raster.json`.
* Where neither `--beam-size` nor the file's `incident_beam_size` states a beam, a grid scan reports the grid step in its place rather than zero, and `BEAM_SIZE_SOURCE` in the raster report says which was used.
* `rugnux --mode raster` takes `--beam-size`, `--raster-protein-threshold`, `--raster-grow-score`, `--raster-min-cells`, `--raster-decisive-score` and `--raster-max-crystals`.
* The per-image ice ring measure is renamed `ice_ring_ratio` (`/entry/MX/iceRingRatio`, plot type `ice_ring_ratio`); the former `ice_ring_score` spelling is still read from older files and streams and still accepted by the API.
* `rugnux --model` reports CC(model, data) - the correlation of the merged intensities with the placed, scaled model - by resolution shell, on the same shells as CC1/2, with the reflection count and a significance for each.
+1 -1
View File
@@ -224,7 +224,7 @@ Raster (`--mode raster`):
| Option | Description |
| --- | --- |
| `--beam-size <x>[,<y>]` | Beam size at the sample in um, x then y; one value is a square beam. Default: the file's `incident_beam_size`. The reported crystal sizes are measured and still contain the beam, so this says what a consumer has to take back out — and since that removal is a subtraction of two covariance matrices, a beam given as square when it is not rotates the reported crystal axis |
| `--beam-size <x>[,<y>]` | Beam size at the sample in um, x then y; one value is a square beam. Default: the file's `incident_beam_size`; where the file states none, the grid step stands in, since a raster is stepped at about the beam size and reporting zero would say the sizes are exact when they still contain a beam. `BEAM_SIZE_SOURCE` in the report says which of the three was used. The reported crystal sizes are measured and still contain the beam, so this says what a consumer has to take back out — and since that removal is a subtraction of two covariance matrices, a beam given as square when it is not rotates the reported crystal axis |
| `--raster-protein-threshold <num>` | Protein score a cell must exceed to count as diffracting (default `0.50`) |
| `--raster-grow-score <num>` | Once a patch has a cell above `--raster-protein-threshold` it is grown out to this score (default `0.35`), so a crystal is not broken in two by one cell that fell just under the higher bar. Growth cannot start on its own — a patch that never reaches the higher bar is not a patch — so lowering this cannot invent a crystal |
| `--raster-min-cells <num>` | Cells a connected patch must have before it is reported as a crystal (default `3`). A smaller patch is still reported when its best cell reaches `--raster-decisive-score` |
@@ -56,8 +56,11 @@ GridScanResult AnalyzeGridScan(const ScanResult &scan,
const int32_t n_label = label.empty() ? 0 : *std::max_element(label.begin(), label.end());
GridScanResult result;
result.beam_size_x_um = beam_size_x_um;
result.beam_size_y_um = beam_size_y_um;
// An unstated beam falls back to the grid step. A raster is stepped at about the beam size, so
// the step is the best proxy there is, and it is a far better one than zero: zero tells a
// consumer the extents are exact when they still contain a whole beam.
result.beam_size_x_um = beam_size_x_um > 0.0f ? beam_size_x_um : step_x;
result.beam_size_y_um = beam_size_y_um > 0.0f ? beam_size_y_um : step_y;
for (int32_t l = 1; l <= n_label; l++) {
std::vector<int64_t> cell;
@@ -24,6 +24,9 @@
// axes - and a needle at an arbitrary angle is exactly the case this whole design exists for.
// beam_size_x_um/beam_size_y_um are only copied into the result, so it says what the sizes contain.
// beam_size_x_um/beam_size_y_um are the beam at the sample. Pass 0 where it is not known: the grid
// step is then reported in its place, since a raster is stepped at about the beam size and a zero
// beam would tell a consumer the extents are exact when they still contain one.
GridScanResult AnalyzeGridScan(const ScanResult &scan,
const GridScanSettings &grid,
float beam_size_x_um,
+13 -9
View File
@@ -120,7 +120,7 @@ void print_usage() {
std::cout << std::endl;
std::cout << " Raster (--mode raster)" << std::endl;
std::cout << " --beam-size <x>[,<y>] Beam size at the sample in um, x then y; one value is a square beam. Default: the file's incident_beam_size. The reported crystal sizes are MEASURED and still contain the beam, so this is what a consumer takes back out - and because that removal is a covariance subtraction, a beam given as square when it is not rotates the reported crystal axis" << std::endl;
std::cout << " --beam-size <x>[,<y>] Beam size at the sample in um, x then y; one value is a square beam. Default: the file's incident_beam_size, and where the file states none, the grid step - a raster is stepped at about the beam size, and reporting zero would say the sizes are exact when they still contain a beam. BEAM_SIZE_SOURCE in the report says which was used. The reported crystal sizes are MEASURED and still contain the beam, so this is what a consumer takes back out - and because that removal is a covariance subtraction, a beam given as square when it is not rotates the reported crystal axis" << std::endl;
std::cout << " --raster-protein-threshold <num> Protein score a cell must exceed to count as diffracting (default: 0.50)" << std::endl;
std::cout << " --raster-grow-score <num> Once a patch has a cell above --raster-protein-threshold it is grown out to this score (default: 0.35), so a crystal is not broken in two by one cell that fell just under the higher bar. A patch that never reaches the higher bar is not a patch at all, so lowering this cannot invent one" << std::endl;
std::cout << " --raster-min-cells <num> Cells a connected patch must have before it is reported as a crystal (default: 3). A smaller patch is still reported when its best cell reaches --raster-decisive-score" << std::endl;
@@ -717,8 +717,8 @@ static int RunRugnux(int argc, char **argv) {
std::string calibrant = "lab6"; // --calibrant
CalibrationMethod calibration_method = CalibrationMethod::Rings; // --calibration
bool calibration_refine_tilt = true; // --no-refine-tilt
// --mode raster. The beam size is empty until it is stated: the file's incident_beam_size is read
// once the dataset is open, and --beam-size overrides it there.
// --mode raster. Empty until stated: the file's incident_beam_size is read once the dataset is
// open, --beam-size overrides it there, and the grid step stands in where neither says.
std::optional<float> beam_size_x_um, beam_size_y_um; // --beam-size
// What counts as a crystal (--raster-*). The defaults are the ones AnalyzeGridScan states.
GridScanAnalysisSettings raster_analysis;
@@ -2164,9 +2164,10 @@ static int RunRugnux(int argc, char **argv) {
}
// The beam is not taken out of the reported crystal sizes, but it is reported alongside them
// so a consumer can do it. --beam-size wins over the file, and a file that states nothing
// leaves the size at zero rather than at a guess: a made-up beam would be deconvolved as if
// it had been measured, and an anisotropic beam given as square rotates the crystal axis.
// so a consumer can do it. --beam-size wins over the file; where neither states one the grid
// step stands in, since a raster is stepped at about the beam size and a zero would tell a
// consumer the extents are exact when they still contain a whole beam. BEAM_SIZE_SOURCE says
// which of the three it was, because an anisotropic beam given as square rotates the axis.
RasterSettings raster_settings;
raster_settings.analysis = raster_analysis;
if (beam_size_x_um) {
@@ -2174,10 +2175,13 @@ static int RunRugnux(int argc, char **argv) {
} else {
beam_size_x_um = experiment.GetBeamSizeX_um();
beam_size_y_um = experiment.GetBeamSizeY_um();
raster_settings.beam_size_source = beam_size_x_um ? "FILE" : "NOT_STATED";
raster_settings.beam_size_source = beam_size_x_um ? "FILE" : "GRID_STEP";
}
raster_settings.beam_size_x_um = beam_size_x_um.value_or(0.0f);
raster_settings.beam_size_y_um = beam_size_y_um.value_or(0.0f);
// Unstated: AnalyzeGridScan reports the grid step in the beam's place, so the report says
// the same thing the analysis used rather than a zero the analysis did not.
const auto &g = experiment.GetGridScan();
raster_settings.beam_size_x_um = beam_size_x_um.value_or(g ? std::fabs(g->GetGridStepX_um()) : 0.0f);
raster_settings.beam_size_y_um = beam_size_y_um.value_or(g ? std::fabs(g->GetGridStepY_um()) : 0.0f);
ProcessConfig config;
config.mode = AnalysisMode::Grid;
+23
View File
@@ -331,3 +331,26 @@ TEST_CASE("AnalyzeGridScan follows the snake and the step signs", "[AnalyzeGridS
CHECK(a.y_um == Catch::Approx(b.y_um));
CHECK(snake.Rearrange(b.image_number) == plain.Rearrange(a.image_number));
}
TEST_CASE("AnalyzeGridScan falls back to the grid step for an unstated beam", "[AnalyzeGridScan]") {
auto grid = MakeGrid(8, 8, 20.0f, 16.0f);
const std::vector<std::pair<int64_t, int64_t>> blob{{2, 2}, {3, 2}, {2, 3}, {3, 3}};
const auto scan = MakeScan(8, 8, blob, 0.9f);
// Stated: echoed back untouched, whatever the step is.
const auto stated = AnalyzeGridScan(scan, grid, 80.0f, 20.0f);
CHECK(stated.beam_size_x_um == Catch::Approx(80.0));
CHECK(stated.beam_size_y_um == Catch::Approx(20.0));
// Unstated: the step stands in. Zero would tell a consumer the extents are exact when they
// still contain a whole beam, and the two axes must not collapse to one value.
const auto unstated = AnalyzeGridScan(scan, grid, 0.0f, 0.0f);
CHECK(unstated.beam_size_x_um == Catch::Approx(20.0));
CHECK(unstated.beam_size_y_um == Catch::Approx(16.0));
// The extents themselves are measured and must not move with the beam.
REQUIRE(stated.crystals.size() == 1);
REQUIRE(unstated.crystals.size() == 1);
CHECK(stated.crystals[0].major_um == Catch::Approx(unstated.crystals[0].major_um));
CHECK(stated.crystals[0].minor_um == Catch::Approx(unstated.crystals[0].minor_um));
}