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
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:
co-authored by
Claude Fable 5
parent
df0d86d9d1
commit
626f1f0500
+1
-1
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user