From aa5a31b345db07eaf7c2920e4b17c99409b4b991 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 8 Sep 2026 09:11:26 +0200 Subject: [PATCH] grid scan: an unstated beam is reported as the grid step, not as zero 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 Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N --- docs/CHANGELOG.md | 1 + docs/RUGNUX_ADVANCED.md | 2 +- .../grid_scan_analysis/AnalyzeGridScan.cpp | 7 ++++-- .../grid_scan_analysis/AnalyzeGridScan.h | 3 +++ rugnux/rugnux_cli.cpp | 22 ++++++++++-------- tests/AnalyzeGridScanTest.cpp | 23 +++++++++++++++++++ 6 files changed, 46 insertions(+), 12 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 72a606008..57d80ad21 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 `_raster_report.txt` and `_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. diff --git a/docs/RUGNUX_ADVANCED.md b/docs/RUGNUX_ADVANCED.md index c7cd7c70d..9dbcea31e 100644 --- a/docs/RUGNUX_ADVANCED.md +++ b/docs/RUGNUX_ADVANCED.md @@ -224,7 +224,7 @@ Raster (`--mode raster`): | Option | Description | | --- | --- | -| `--beam-size [,]` | 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 [,]` | 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 ` | Protein score a cell must exceed to count as diffracting (default `0.50`) | | `--raster-grow-score ` | 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 ` | 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` | diff --git a/image_analysis/grid_scan_analysis/AnalyzeGridScan.cpp b/image_analysis/grid_scan_analysis/AnalyzeGridScan.cpp index 14e919de3..70811f2e4 100644 --- a/image_analysis/grid_scan_analysis/AnalyzeGridScan.cpp +++ b/image_analysis/grid_scan_analysis/AnalyzeGridScan.cpp @@ -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 cell; diff --git a/image_analysis/grid_scan_analysis/AnalyzeGridScan.h b/image_analysis/grid_scan_analysis/AnalyzeGridScan.h index 6d9529b33..cce8a4ebf 100644 --- a/image_analysis/grid_scan_analysis/AnalyzeGridScan.h +++ b/image_analysis/grid_scan_analysis/AnalyzeGridScan.h @@ -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, diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 0a8ca1598..25c21b7ee 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -120,7 +120,7 @@ void print_usage() { std::cout << std::endl; std::cout << " Raster (--mode raster)" << std::endl; - std::cout << " --beam-size [,] 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 [,] 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 Protein score a cell must exceed to count as diffracting (default: 0.50)" << std::endl; std::cout << " --raster-grow-score 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 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 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; diff --git a/tests/AnalyzeGridScanTest.cpp b/tests/AnalyzeGridScanTest.cpp index 9fe5c0821..dfa0c1d20 100644 --- a/tests/AnalyzeGridScanTest.cpp +++ b/tests/AnalyzeGridScanTest.cpp @@ -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> 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)); +}