From 626f1f0500be317c4c14aadd8eee87c574785bce Mon Sep 17 00:00:00 2001 From: GotthardG <51994228+GotthardG@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:09:25 +0200 Subject: [PATCH] feat(models): ship the grid-scan decision to AareDB GridScanResult gains contour_cells -- the (nx, ny) cells of the chosen 50 % blob, so consumers can draw the decision's own footprint over the sample image without recomputing the map. New GridScanDecision wraps the result verbatim with provenance (algorithm, version, the Thresholds used) and rides on RasterPayloadModel.decision (optional -- DAQs that have not adopted it stay valid). jfjoch-client floor moves to 1.0.0rc166: the embedded ScanResult defines the field contract, and rc166 adds per-image ice + latt_count and rotation_bravais. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HiGzkkuiZXei894cnJfgSg --- pyproject.toml | 2 +- src/aarecommon/math/jfjoch_gridscan_union.py | 9 +++ src/aarecommon/models/gridscan_decision.py | 36 +++++++++++ src/aarecommon/models/raster_grid.py | 4 ++ tests/test_gridscan_decision.py | 68 ++++++++++++++++++++ 5 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/aarecommon/models/gridscan_decision.py create mode 100644 tests/test_gridscan_decision.py diff --git a/pyproject.toml b/pyproject.toml index be84cf3..1bd64dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "pyyaml", - "jfjoch-client", + "jfjoch-client>=1.0.0rc166", "pydantic", "numpy", "scipy", diff --git a/src/aarecommon/math/jfjoch_gridscan_union.py b/src/aarecommon/math/jfjoch_gridscan_union.py index 1dfadc4..7019ccc 100644 --- a/src/aarecommon/math/jfjoch_gridscan_union.py +++ b/src/aarecommon/math/jfjoch_gridscan_union.py @@ -199,6 +199,12 @@ class GridScanResult(BaseModel): warnings: list[str] = Field( default_factory=list, description="Reasons to distrust a found=True result" ) + contour_cells: Optional[list[tuple[int, int]]] = Field( + None, + description="(nx, ny) of every collected cell inside the chosen 50 % " + "blob -- the decision's own footprint, for drawing it over the sample " + "image without recomputing the map", + ) jpeg: Optional[bytes] = Field( None, exclude=True, @@ -610,6 +616,9 @@ def analyse( if keep.shape == inside.shape: inside = inside & keep counts.n_fragments = n_frag + if cell.any(): + yy_c, xx_c = np.nonzero(cell) + out.contour_cells = [(int(x), int(y)) for y, x in zip(yy_c, xx_c)] if line: # One position wide: the 50 % crossings either side of the peak. The outer diff --git a/src/aarecommon/models/gridscan_decision.py b/src/aarecommon/models/gridscan_decision.py new file mode 100644 index 0000000..e4e23c9 --- /dev/null +++ b/src/aarecommon/models/gridscan_decision.py @@ -0,0 +1,36 @@ +"""The grid-scan centring decision, as taken at the beamline. + +The DAQ runs the analysis (``aarecommon.math.jfjoch_gridscan_union``) to pick +the collection position; this wraps its result verbatim with provenance and +rides on ``RasterPayloadModel.decision`` into AareDB, whose Results viewer +displays THE decision instead of recomputing one -- two implementations of the +same analysis drifting apart is how a UI ends up contradicting the beamline. + +The pictures are excluded from the result's serialization by the model itself; +the DAQ ships them through the ordinary image pipeline. +""" + +from typing import Any, Optional + +from pydantic import BaseModel, Field + +from aarecommon.math.jfjoch_gridscan_union import GridScanResult + +GRIDSCAN_DECISION_CONTRACT = 1 + + +class GridScanDecision(BaseModel): + contract: int = GRIDSCAN_DECISION_CONTRACT + algorithm: str = Field( + description="Which analysis took the decision, e.g. a " + "GridscanAnalysisMode value such as 'jfjoch_gridscan_union'" + ) + algorithm_version: Optional[str] = Field( + None, description="aarecommon version (or commit) the DAQ ran" + ) + thresholds: Optional[dict[str, Any]] = Field( + None, + description="Thresholds.model_dump() used at decision time, so a " + "stored decision states its own tuning", + ) + result: GridScanResult diff --git a/src/aarecommon/models/raster_grid.py b/src/aarecommon/models/raster_grid.py index ed9a1b0..db37e4a 100644 --- a/src/aarecommon/models/raster_grid.py +++ b/src/aarecommon/models/raster_grid.py @@ -6,6 +6,7 @@ from pydantic import AfterValidator, BaseModel, Field from aarecommon.math.coordinate import Coordinate, SmargonCoordinate, positive_coords from aarecommon.math.sample_geometry import SampleGeometryModel +from aarecommon.models.gridscan_decision import GridScanDecision class RasterGridRequest(BaseModel): @@ -92,3 +93,6 @@ class RasterPayloadModel(BaseModel): cell_size_pxl: Annotated[Coordinate, AfterValidator(positive_coords)] beam_mark_pxl: tuple[float, float] beam_size_mm: Annotated[Coordinate, AfterValidator(positive_coords)] + # The centring decision as taken at the beamline (None from DAQs that + # have not adopted it yet); AareDB stores and displays it verbatim. + decision: GridScanDecision | None = None diff --git a/tests/test_gridscan_decision.py b/tests/test_gridscan_decision.py new file mode 100644 index 0000000..41866a8 --- /dev/null +++ b/tests/test_gridscan_decision.py @@ -0,0 +1,68 @@ +"""GridScanDecision wire contract: wraps the analysis result verbatim.""" + +import json + +import numpy as np + +from aarecommon.math.jfjoch_gridscan_union import Thresholds, analyse +from aarecommon.models.gridscan_decision import GridScanDecision +from aarecommon.models.raster_grid import RasterPayloadModel + + +def _synthetic_scan(ny_n=20, nx_n=20): + """A gaussian protein blob on a flat loop, as plain dicts (jfjoch-free).""" + rng = np.random.default_rng(0) + yy, xx = np.mgrid[0:ny_n, 0:nx_n] + blob = 80 * np.exp(-(((xx - 7) ** 2 + (yy - 12) ** 2) / 8.0)) + images = [ + { + "number": int(y * nx_n + x), + "nx": int(x), + "ny": int(y), + "spots": int(blob[y, x] + 10 + rng.integers(0, 3)), + "spots_low_res": int(4 + rng.integers(0, 2)), + "spots_ice": 0, + "bkg": float(30 + 40 * np.exp(-(((x - 9) ** 2 + (y - 10) ** 2) / 90.0))), + "res": 2.0, + } + for y in range(ny_n) + for x in range(nx_n) + ] + return {"file_prefix": "decision-test", "images": images} + + +def test_decision_round_trips_without_pictures(): + th = Thresholds() + r = analyse(_synthetic_scan(), {"step_x_um": 10.0, "step_y_um": 10.0}, thresholds=th) + assert r.found + assert r.contour_cells, "the chosen 50 % blob must ship its cells" + assert all( + 0 <= x < r.n_fast and 0 <= y < r.n_slow for x, y in r.contour_cells + ) + # the centre lies within the contour's bounding box + xs = [x for x, _ in r.contour_cells] + ys = [y for _, y in r.contour_cells] + assert min(xs) - 1 <= r.centre.nx <= max(xs) + 1 + assert min(ys) - 1 <= r.centre.ny <= max(ys) + 1 + + d = GridScanDecision( + algorithm="jfjoch_gridscan_union", + algorithm_version="test", + thresholds=th.model_dump(), + result=r, + ) + wire = json.loads(d.model_dump_json()) + assert wire["contract"] == 1 + assert "jpeg" not in wire["result"], "pictures never ride the JSON" + assert wire["result"]["centre"]["image_number"] == r.centre.image_number + assert wire["thresholds"]["object_union_spots"] is True + + back = GridScanDecision.model_validate(wire) + assert back.result.centre.nx == r.centre.nx + assert [tuple(c) for c in back.result.contour_cells] == r.contour_cells + + +def test_payload_decision_is_optional(): + fields = RasterPayloadModel.model_fields + assert "decision" in fields + assert fields["decision"].default is None