4 Commits
Author SHA1 Message Date
leonarski_fandClaude Opus 5 0b567061ce grid scan: the run finds the crystals and the viewer can ask it to
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 9m42s
Build Packages / build:windows:nocuda (push) Successful in 18m11s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 18m22s
Build Packages / build:windows:cuda (push) Successful in 20m31s
Build Packages / build:viewer-tgz:cpu (push) Successful in 20m57s
Build Packages / build:viewer-tgz:cuda (push) Successful in 22m25s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 22m33s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 28m0s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 28m37s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m49s
Build Packages / build:rugnux:windows (push) Successful in 11m34s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 23m11s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 28m23s
Build Packages / build:rpm (rocky9) (push) Successful in 25m25s
Build Packages / build:rpm (rocky8) (push) Successful in 29m21s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 23m8s
Build Packages / Generate python client (push) Successful in 45s
Build Packages / Create release (push) Skipped
Build Packages / Build documentation (push) Successful in 1m12s
Build Packages / XDS test (durin plugin) (push) Successful in 10m58s
Build Packages / DIALS test (push) Successful in 26m15s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 28m3s
Build Packages / XDS test (neggia plugin) (push) Successful in 8m36s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 10m20s
Build Packages / Unit tests (push) Successful in 1h26m10s
A grid scan is analysed once, from the completed map, and the answer belongs to the run
rather than to whoever happened to call it. rugnux computed it after RunPipeline had
already written the end message, so the crystals reached the raster report and nothing
else - the _process.h5 carried the per-cell scores and no crystal list, and a viewer
re-opening that file had nothing to draw.

Rugnux now accumulates the raster with ScanResultGenerator, the same accumulator the
online receiver fills, runs AnalyzeGridScan at the end message for the same stated reason
the receiver does, sets EndMessage::grid_crystals so the file gets /entry/MX/crystals, and
returns the result on ProcessResult. The CLI consumes that instead of analysing the map a
second time; the raster report and JSON are unchanged, verified against the previous
binary on a real raster (identical crystal, identical report; the ice score differs in the
seventh decimal, which the same binary does run to run).

The accumulator keyed a cell on the message's number, which for rugnux is the ordinal of
the images -s/-e/--stride selected, not the image's place in the raster. It now reads
original_number where there is one, as the writer already does; both receivers set it
equal to number, so nothing online changes.

jfjoch_viewer offers Grid as a fourth mode beside MX, AzInt and Calib, configures the job
with spot finding on and indexing from the experiment's grid-scan settings, and refuses
the mode on a dataset that has no grid scan rather than scoring every image and reporting
nothing. The shared azimuthal section is shown for it, which it needs - the ice score's
radial channel reads that profile.

Verified end to end: Grid, then Analyze dataset, on a stored raster leaves the composite
map on screen with a frame at each crystal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
2026-09-08 12:20:03 +02:00
leonarski_fandClaude Opus 5 aa5a31b345 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
2026-09-08 09:11:26 +02:00
leonarski_fandClaude Opus 5 cdb60c2916 grid scan: one cell is a crystal when that cell is decisive, and the list can be capped
The minimum patch size was a flat floor, so a crystal that covers one or two grid
points was thrown away however strong its diffraction. It is now a floor OR a piece
of evidence: a patch is reported when it has min_blob_cells cells, or when its best
cell reaches decisive_protein_score. One condition, and both halves are parameters.

The bar is the patch PEAK, not the patch mean. A two-cell patch with one strong cell
and one marginal one is the case this exists for, and the mean averages exactly that
evidence away. Over the 67 labelled rasters the two populations do not overlap: no
water raster reaches a peak of 0.15 and no ice raster reaches 0.50, while the weakest
protein raster peaks at 0.67 - so 0.6, the middle of that gap, is the default. The
peak is reported beside the mean, in the table and in the JSON, so an admission can
be checked against the number that decided it.

max_crystals caps the returned list after the sort, best first; 0, the default, is all
of them, because a crystal that was found and then dropped is information the caller
cannot get back.

The four parameters now travel as one GridScanAnalysisParameters, which is the shape
the GridScanAnalysisSettings class in common/ is to take: when it lands it replaces
this struct in the signature and nothing else changes.

On the corpus this reaches 17/17 protein loops (it was 16/17 - the miss was a crystal
covering two grid points of a 4x4 raster) with water still 0/4 and ice still 0/3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
2026-09-08 07:15:30 +02:00
leonarski_fandClaude Opus 5 ee0ad8d149 grid scan: a completed raster is reduced to the crystals in it, oriented and ranked
AnalyzeGridScan takes a finished ScanResult plus its GridScanSettings and returns the
list of crystals the raster hit, sorted by score so element 0 is the one to collect.
Pure function - no I/O, no FPGA, no JSON. Today the list holds nought or one entry; N
is the point of the shape.

The per-image protein score is scattered back onto the display grid through
Rearrange, which already knows the snake order, the vertical flag and the step signs,
thresholded, and labelled into blobs. Each blob is then measured:

- Centre is a weighted centroid, pulled towards the cells that diffract best. The pull
  uses the RANK of the resolution inside the blob, never its value, so a salt grain
  reporting an absurd 0.8 A weighs exactly what a genuine best cell weighs and cannot
  drag the centre however extreme its number. A cell with no resolution gets the lowest
  weight rather than being dropped. The centroid of a banana- or L-shaped blob can land
  outside the blob, where no image exists, so image_number is snapped to the nearest
  cell that was actually collected.

- Second moments are taken in micrometres, not in cells. A 20 x 16 um raster is
  ordinary and moments in cell units give a wrong angle - eight degrees wrong on the
  staircase in the tests. The angle is an axis, so it lives in [0,180) and wraps there.

- The axis DIRECTION comes from the eigenvector but the LENGTH from the projected
  extent, because "how far do I scan" is an extent question and the constant taking a
  second moment to a length assumes a shape a blob of five cells does not have. Where
  the two disagree about which axis is longer - a moment dominated by clumps at the
  ends - the extents are swapped and the angle turned a quarter turn, so major_um >=
  minor_um with angle_deg along it is an invariant a consumer can draw a frame from.

- score is the MEAN protein score over the blob, not the peak: the score saturates, so
  the peak is 1.0 for every real crystal and ranks nothing. res_A is the 25th
  percentile, not the minimum, the minimum being precisely where a salt spot or a hot
  pixel shows up; it is NaN when nothing in the blob measured a resolution.

Sizes are measured and the beam is left in them. The beam is already in the file as
incident_beam_size, so a consumer can deconvolve reproducibly and reversibly instead
of inheriting ours; the result carries the beam size so it says what the extents
contain. The header records that removing an anisotropic beam is a covariance-matrix
subtraction followed by re-diagonalisation, not a per-axis quadrature removal, which
is silently wrong whenever the crystal is not aligned with the grid - the needle case
this design exists for.

Labelling is a small dense flood fill in common/, 8-connected. StrongPixelSet::sparseccl
is the wrong abstraction for a dense grid map: sparse union-find over raster-ordered
strong pixels, hardcoded module dimensions, a 4000-pixel cap, spot-shape acceptance,
and an FPGA header. 8-connected rather than 4 because where the step is coarser than
the beam a needle at 45 degrees lands as corner-touching cells; under 4-connectivity
that breaks into single cells and the minimum-size rule then discards the crystal
entirely, which is the case oriented axes exist to catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
2026-09-07 23:39:16 +02:00