feat(models): ship the grid-scan decision to AareDB
CI / lint (push) Skipped
CI / test (3.11) (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / lint (pull_request) Failing after 1m46s
CI / test (3.11) (pull_request) Skipped
CI / test (3.12) (pull_request) Skipped
CI / test (3.13) (pull_request) Skipped

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HiGzkkuiZXei894cnJfgSg
This commit is contained in:
GotthardG
2026-09-03 11:09:25 +02:00
co-authored by Claude Fable 5
parent df0d86d9d1
commit 626f1f0500
5 changed files with 118 additions and 1 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"pyyaml",
"jfjoch-client",
"jfjoch-client>=1.0.0rc166",
"pydantic",
"numpy",
"scipy",
@@ -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
@@ -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
+4
View File
@@ -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
+68
View File
@@ -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