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