From 53e29bece309a8a32d305aa7de548e385f86765c Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 26 Jun 2026 09:45:43 +0200 Subject: [PATCH 01/10] raster auto-center: zoom-to-fit loop + loop_face padding + optional crystal union Before the first auto-center raster, zoom so the loop fills at most 1/3 of the frame, pad loop_face grids, and optionally extend the grid to cover crystals outside the loop. - ml_bounding_box: add build_ml_raster_plan() (single prediction via predict_all_best) returning the grid request + raw loop_all/loop_face boxes + image size. Grid box is loop_face (else loop_all); loop_face is padded by daq.auto_raster.loop_face_padding_fraction (15% default); when daq.auto_raster.include_crystal is set, the grid is unioned with any crystal boxes lying beyond the loop. get_ml_bounding_box (manual button / endpoint) is unchanged. - config: add zoom_for_pixel_to_mm(), the inverse of pixel_to_mm. - raster service execute_auto_center: after the first detection (reused, no re-predict), pick loop_all if detected and not clipped at the frame edge else loop_face, compute the zoom that makes its larger side 1/3 of the frame, clamp to daq.hardware.zoom_min/max, set zoom and re-expose. - x06da.yaml: zoom_min/max + daq.auto_raster block. - tests for box selection, padding, crystal union and the clipped-loop_all rule. Co-Authored-By: Claude Opus 4.8 --- src/aare/common/config/x06da.yaml | 6 + src/aare/daq/config.py | 22 +++ .../daq/operations/common/ml_bounding_box.py | 166 +++++++++++++++++- src/aare/daq/operations/raster/service.py | 100 ++++++++++- .../daq/operations/test_ml_raster_plan.py | 131 ++++++++++++++ 5 files changed, 418 insertions(+), 7 deletions(-) create mode 100644 tests/unit/daq/operations/test_ml_raster_plan.py diff --git a/src/aare/common/config/x06da.yaml b/src/aare/common/config/x06da.yaml index 2bf17cd8..95d8bbaa 100644 --- a/src/aare/common/config/x06da.yaml +++ b/src/aare/common/config/x06da.yaml @@ -30,6 +30,8 @@ daq: lens_magnification: 10 #or 5 currently default_detector_distance_minimum: 86 default_detector_distance_maximum: 900 + zoom_min: 1 # camera zoom travel limits (used to clamp auto-center zoom-to-fit) + zoom_max: 1000 db: aaredb_url: "https://mx-aaredb-dmz-01.psi.ch/dispatcher" @@ -50,3 +52,7 @@ daq: detector_limit_modifier: 2.0 maximum_flux: 4e11 + + auto_raster: + loop_face_padding_fraction: 0.15 # pad loop_face raster boxes by 15% per side + include_crystal: false # extend the grid to cover crystals outside the loop box diff --git a/src/aare/daq/config.py b/src/aare/daq/config.py index c1327e42..c6c8d687 100644 --- a/src/aare/daq/config.py +++ b/src/aare/daq/config.py @@ -625,6 +625,28 @@ class BeamlineConfig: lens_magnification = DEFAULT_LENS_MAGNIFICATION return base_pixel_in_mm * (DEFAULT_LENS_MAGNIFICATION / lens_magnification) + def zoom_for_pixel_to_mm(self, target_pixel_in_mm: float) -> float: + """Inverse of :meth:`pixel_to_mm`: the zoom at which one pixel covers + ``target_pixel_in_mm`` millimetres. + + pixel_to_mm(z) = lens_factor / (b * exp(a*z)) => + z = ln(lens_factor / (b * target)) / a + """ + if target_pixel_in_mm <= 0: + raise ValueError(f"target_pixel_in_mm must be > 0, got {target_pixel_in_mm}") + cfg = self.settings + a = cfg.camera_translation_factor_a + b = cfg.camera_translation_factor_b + lens_magnification = cfg_get("daq.hardware.lens_magnification", DEFAULT_LENS_MAGNIFICATION) + try: + lens_magnification = float(lens_magnification) + except (TypeError, ValueError): + lens_magnification = DEFAULT_LENS_MAGNIFICATION + if lens_magnification <= 0: + lens_magnification = DEFAULT_LENS_MAGNIFICATION + lens_factor = DEFAULT_LENS_MAGNIFICATION / lens_magnification + return float(np.log(lens_factor / (b * target_pixel_in_mm)) / a) + @property def beam_center(self) -> Tuple[float, float]: tmp_x = self.__client.get(f"{self.__bl}:beam_center_x") diff --git a/src/aare/daq/operations/common/ml_bounding_box.py b/src/aare/daq/operations/common/ml_bounding_box.py index 19d4df69..0428f225 100644 --- a/src/aare/daq/operations/common/ml_bounding_box.py +++ b/src/aare/daq/operations/common/ml_bounding_box.py @@ -1,9 +1,11 @@ import time +from dataclasses import dataclass from math import ceil, floor from typing import Callable import cv2 +from aare.common.beamline import cfg_get from aare.common.coordinate import Coordinate, SmargonCoordinate from aare.common.exception_handler import AutoRasterSampleSkipped from aare.common.logger_events import ( @@ -12,10 +14,39 @@ from aare.common.logger_events import ( merge_log_context, sample_log_context, ) -from aare.common.models import SampleShortInfo +from aare.common.models import SampleShortInfo, MLBoxType from aare.common.raster_grid import RasterGridRequest from aare.common.sample_geometry import SampleGeometryModel -from aare.daq.mlbox import MLBoxPredictionResult, MlBox +from aare.daq.mlbox import MLBoxPredictionResult, MLBoxPredictionsResult, MlBox + + +BoxTuple = tuple[float, float, float, float] + + +@dataclass +class MLRasterPlan: + """Result of an auto-center ML detection: the raster grid request plus the + raw loop boxes (at the current zoom) needed to drive zoom-to-fit.""" + grid_request: RasterGridRequest + loop_all_box: BoxTuple | None + loop_face_box: BoxTuple | None + image_width: int | None + image_height: int | None + + +def _box_tuple(model) -> BoxTuple | None: + if model is None or model.box is None: + return None + return (model.box.top_x, model.box.top_y, model.box.bottom_x, model.box.bottom_y) + + +def _box_extends_beyond(inner: BoxTuple, outer: BoxTuple) -> bool: + return (inner[0] < outer[0] or inner[1] < outer[1] + or inner[2] > outer[2] or inner[3] > outer[3]) + + +def _box_union(a: BoxTuple, b: BoxTuple) -> BoxTuple: + return (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])) def scale_auto_raster_grid( @@ -133,6 +164,32 @@ def get_ml_bounding_box( ), ) + return _box_to_raster_request( + x1=x1, y1=y1, x2=x2, y2=y2, + sample=sample, + sample_geometry=geom, + logger=logger, + filename=filename, + sample_id=sample_id, + max_images=max_images, + min_cell_size_mm=min_cell_size_mm, + skip_if_exceed_max_image_threshold=skip_if_exceed_max_image_threshold, + ) + + +def _box_to_raster_request( + *, + x1: float, y1: float, x2: float, y2: float, + sample: SampleShortInfo | None, + sample_geometry: SampleGeometryModel, + logger, + filename: str | None, + sample_id: int | None, + max_images: int, + min_cell_size_mm: float, + skip_if_exceed_max_image_threshold: bool, +) -> RasterGridRequest: + geom = sample_geometry start_coord = geom.picture_to_smargon(Coordinate(x=x1, y=y1)) grid_size = Coordinate(x=geom.beam_size_mm.x * 0.8, y=geom.beam_size_mm.y * 0.8) n_x = max(1, abs(ceil((x2 - x1) * geom.pixel_in_mm / grid_size.x))) @@ -211,4 +268,109 @@ def get_ml_bounding_box( n_y=n_y, grid_size_mm=grid_size, omega_deg=geom.omega_deg, + ) + + +def build_ml_raster_plan( + *, + mlbox: MlBox, + sample: SampleShortInfo | None, + sample_geometry: SampleGeometryModel, + upload_image: Callable[[int | None, str, object], None], + logger, + filename: str | None = None, + max_images: int, + min_cell_size_mm: float, + skip_if_exceed_max_image_threshold: bool, +) -> MLRasterPlan | None: + """Like :func:`get_ml_bounding_box`, but used by the auto-center path. + + It runs a single prediction and returns the raster grid request together + with the raw ``loop_all`` / ``loop_face`` boxes (so the caller can drive + zoom-to-fit). The grid box is loop_face (else loop_all); a loop_face box is + padded by ``daq.auto_raster.loop_face_padding_fraction`` and, when + ``daq.auto_raster.include_crystal`` is enabled, the box is extended to the + union of itself and any detected crystal boxes that lie beyond it. + """ + time.sleep(0.2) + sample_id = getattr(sample, "db_id", None) + + result: MLBoxPredictionsResult = mlbox.predict_all_best( + return_image=True, + return_bundle_meta=True, + ) + predictions = result.predictions + bundle_image = result.image + + log_ml_bundle_meta( + logger, + f"ml_raster_plan:{filename or 'unnamed'}", + target_point=result.target_point, + focus=result.focus, + ) + + loop_all = predictions.get_best_for_class(MLBoxType.LOOP_ALL) if predictions else None + loop_face = predictions.get_best_for_class(MLBoxType.LOOP_FACE) if predictions else None + + # Grid box: prefer loop_face, else loop_all (matches the legacy (3, 0) order). + grid_model = loop_face if loop_face is not None else loop_all + if grid_model is None: + logger.warning( + "ML raster plan returned no loop detection", + extra={"sample_id": sample_id, "ml_image_name": filename, + "target_point": result.target_point}, + ) + if filename is not None and bundle_image is not None: + upload_image(sample_id, f"{filename}_no_detection", bundle_image) + return None + + x1, y1, x2, y2 = (grid_model.box.top_x, grid_model.box.top_y, + grid_model.box.bottom_x, grid_model.box.bottom_y) + is_loop_face = grid_model is loop_face + + # Pad loop_face boxes a little so the grid isn't cut tight to the face. + if is_loop_face: + frac = float(cfg_get("daq.auto_raster.loop_face_padding_fraction", 0.15)) + pad_x = (x2 - x1) * frac + pad_y = (y2 - y1) * frac + x1, y1, x2, y2 = x1 - pad_x, y1 - pad_y, x2 + pad_x, y2 + pad_y + + # Optionally extend the grid to cover crystals detected outside the loop box. + if cfg_get("daq.auto_raster.include_crystal", False) and predictions is not None: + for crystal in predictions.get_models_for_class(MLBoxType.CRYSTAL): + cbox = (crystal.box.top_x, crystal.box.top_y, crystal.box.bottom_x, crystal.box.bottom_y) + if _box_extends_beyond(cbox, (x1, y1, x2, y2)): + x1, y1, x2, y2 = _box_union((x1, y1, x2, y2), cbox) + logger.info( + "Extended ML raster grid to include crystal outside the loop box", + extra={"sample_id": sample_id, "ml_image_name": filename, + "crystal_box": cbox}, + ) + + if filename is not None and bundle_image is not None: + annotated_image = bundle_image.copy() + cv2.rectangle(annotated_image, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) + upload_image(sample_id, filename, annotated_image) + + grid_request = _box_to_raster_request( + x1=x1, y1=y1, x2=x2, y2=y2, + sample=sample, + sample_geometry=sample_geometry, + logger=logger, + filename=filename, + sample_id=sample_id, + max_images=max_images, + min_cell_size_mm=min_cell_size_mm, + skip_if_exceed_max_image_threshold=skip_if_exceed_max_image_threshold, + ) + + image_height = int(bundle_image.shape[0]) if bundle_image is not None else None + image_width = int(bundle_image.shape[1]) if bundle_image is not None else None + + return MLRasterPlan( + grid_request=grid_request, + loop_all_box=_box_tuple(loop_all), + loop_face_box=_box_tuple(loop_face), + image_width=image_width, + image_height=image_height, ) \ No newline at end of file diff --git a/src/aare/daq/operations/raster/service.py b/src/aare/daq/operations/raster/service.py index 8b5804b3..2496abd0 100644 --- a/src/aare/daq/operations/raster/service.py +++ b/src/aare/daq/operations/raster/service.py @@ -16,12 +16,18 @@ from aare.common.logger_events import ( raster_request_log_context, sample_log_context, ) +from aare.common.beamline import cfg_get from aare.common.models import BeamlineStateEnum from aare.common.raster_grid import CompletedRasterGrid, CompletedRasterGridElem, RasterGridRequest, grid_to_image_id from aare.common.simulate_raster import generate_no_beam_scan_result from aare.daq.mlbox import MLBoxPredictionResult -from aare.daq.operations.common.ml_bounding_box import get_ml_bounding_box +from aare.daq.operations.common.ml_bounding_box import ( + MLRasterPlan, + build_ml_raster_plan, + get_ml_bounding_box, +) from aare.daq.operations.raster.models import RasterBoundingBoxResult, RasterContext +from aare.devices.area_detector import AutoEnum class RasterService: @@ -120,6 +126,85 @@ class RasterService: skip_if_exceed_max_image_threshold=self.ctx.settings.auto_raster_skip_if_exceed_max_image_threshold, ) + def ml_raster_plan( + self, + sample_id: int | None = None, + filename: str | None = None, + ) -> MLRasterPlan | None: + return build_ml_raster_plan( + mlbox=self.ctx.deps.mlbox, + sample=self.ctx.sample, + sample_geometry=self.ctx.sample_geometry, + upload_image=self.ctx.deps.aare.upload_image, + logger=self.logger, + filename=filename, + max_images=self.ctx.settings.auto_raster_max_images, + min_cell_size_mm=self.ctx.settings.auto_raster_min_cell_size_mm, + skip_if_exceed_max_image_threshold=self.ctx.settings.auto_raster_skip_if_exceed_max_image_threshold, + ) + + @staticmethod + def _box_touches_frame_edge(box, width: int | None, height: int | None, margin: int = 2) -> bool: + if width is None or height is None: + return False + x1, y1, x2, y2 = box + return x1 <= margin or y1 <= margin or x2 >= width - margin or y2 >= height - margin + + def _zoom_to_fit_box(self, plan: MLRasterPlan) -> tuple | None: + """Pick the box that drives zoom-to-fit: loop_all if detected and not + clipped at the frame edge, otherwise loop_face.""" + if plan.loop_all_box is not None and not self._box_touches_frame_edge( + plan.loop_all_box, plan.image_width, plan.image_height + ): + return plan.loop_all_box + return plan.loop_face_box + + def _apply_zoom_to_fit(self, plan: MLRasterPlan) -> None: + """Zoom so the chosen loop box covers at most 1/3 of the frame.""" + box = self._zoom_to_fit_box(plan) + if box is None or not plan.image_width or not plan.image_height: + return + + x1, y1, x2, y2 = box + box_px = max(x2 - x1, y2 - y1) + if box_px <= 0: + return + screen_px = min(plan.image_width, plan.image_height) + + current_zoom = self.ctx.deps.devs.zoom + pixel_in_mm_now = self.ctx.deps.cfg.pixel_to_mm(current_zoom) + box_mm = box_px * pixel_in_mm_now + # Target mm-per-pixel so the box spans exactly screen/3. + target_pixel_in_mm = 3.0 * box_mm / screen_px + try: + target_zoom = self.ctx.deps.cfg.zoom_for_pixel_to_mm(target_pixel_in_mm) + except (ValueError, ZeroDivisionError) as e: + self.logger.warning(f"Skipping auto-center zoom-to-fit: {e}") + return + + zoom_min = float(cfg_get("daq.hardware.zoom_min", 1.0)) + zoom_max = float(cfg_get("daq.hardware.zoom_max", 1000.0)) + target_zoom = max(zoom_min, min(zoom_max, target_zoom)) + + self.logger.info( + "Auto-center zoom-to-fit", + extra=merge_log_context( + sample_log_context(self.ctx.sample), + { + "box_px": box_px, + "screen_px": screen_px, + "current_zoom": current_zoom, + "target_zoom": target_zoom, + "used_loop_all": box is plan.loop_all_box, + }, + ), + ) + + self.ctx.deps.devs.samcam_auto(AutoEnum.AUTO) + self.ctx.deps.devs.set_zoom(target_zoom, wait=True) + time.sleep(0.2) + self.ctx.deps.devs.samcam_auto(AutoEnum.ONCE) + def auto_center_line_scan_top_left( self, *, @@ -598,9 +683,9 @@ class RasterService: comment=f"Raster at {geom.omega_deg:.1f} deg", ) - r = self.ml_bounding_box(sample.db_id, f"ml_{geom.omega_deg:.2f}deg") + plan = self.ml_raster_plan(sample.db_id, f"ml_{geom.omega_deg:.2f}deg") - if r is None: + if plan is None: self.logger.warning( "No ML bounding box found at primary angle during auto-center raster", extra=merge_log_context( @@ -613,9 +698,9 @@ class RasterService: ) self.ctx.deps.devs.aerotech_omega = geom.omega_deg + 90.0 time.sleep(0.2) - r = self.ml_bounding_box(sample.db_id, f"ml_{geom.omega_deg + 90.0:.2f}deg") + plan = self.ml_raster_plan(sample.db_id, f"ml_{geom.omega_deg + 90.0:.2f}deg") - if r is None: + if plan is None: self.logger.error( "Auto-center raster aborted because no ML bounding box was found at either angle", extra=merge_log_context( @@ -629,6 +714,11 @@ class RasterService: ) return None + # Zoom so the loop fills at most 1/3 of the frame before the first raster. + # The grid was built from this same detection (no re-predict). + self._apply_zoom_to_fit(plan) + r = plan.grid_request + self.logger.info( "ML bounding box found for auto-center raster", extra=merge_log_context( diff --git a/tests/unit/daq/operations/test_ml_raster_plan.py b/tests/unit/daq/operations/test_ml_raster_plan.py new file mode 100644 index 00000000..72a53ec1 --- /dev/null +++ b/tests/unit/daq/operations/test_ml_raster_plan.py @@ -0,0 +1,131 @@ +import logging +import types + +import numpy as np +import pytest + +from aare.common.coordinate import Coordinate, SmargonCoordinate +from aare.common.models import MLBoxType, MLOutputModel +from aare.common.sample_geometry import SampleGeometryModel +from aare.daq.mlbox import MLBoxPredictionsResult +from aare.daq.operations.common import ml_bounding_box as mlb +from aare.daq.operations.common.ml_bounding_box import ( + _box_extends_beyond, + _box_union, + build_ml_raster_plan, +) + +logger = logging.getLogger("test_ml_raster_plan") + + +def _geom() -> SampleGeometryModel: + return SampleGeometryModel( + beam_location_pxl=Coordinate(x=500, y=500), + pixel_in_mm=0.001, + aerotech=Coordinate(x=0, y=0), + aerotech_meas=Coordinate(x=0, y=0), + smargon=SmargonCoordinate(sh_mm=Coordinate(x=0, y=0, z=0), phi_deg=0, chi_deg=0), + omega_deg=0, + beam_size_mm=Coordinate(x=0.01, y=0.01), + ) + + +def _fake_mlbox(loop_all=None, loop_face=None, crystals=()): + preds = MLOutputModel() + if loop_all is not None: + preds.add_box(MLBoxType.LOOP_ALL, loop_all, 0.9) + if loop_face is not None: + preds.add_box(MLBoxType.LOOP_FACE, loop_face, 0.8) + for c in crystals: + preds.add_box(MLBoxType.CRYSTAL, c, 0.7) + result = MLBoxPredictionsResult( + predictions=preds if preds.boxes else None, + image=np.zeros((1000, 1000, 3), dtype=np.uint8), + target_point=None, + focus=None, + ) + return types.SimpleNamespace(predict_all_best=lambda **kwargs: result) + + +def _plan(mlbox): + return build_ml_raster_plan( + mlbox=mlbox, + sample=None, + sample_geometry=_geom(), + upload_image=lambda *a, **k: None, + logger=logger, + filename=None, + max_images=100000, + min_cell_size_mm=0.0001, + skip_if_exceed_max_image_threshold=False, + ) + + +def test_box_helpers(): + assert _box_union((1, 1, 3, 3), (2, 0, 5, 4)) == (1, 0, 5, 4) + assert _box_extends_beyond((350, 150, 500, 300), (100, 100, 400, 400)) is True + assert _box_extends_beyond((150, 150, 300, 300), (100, 100, 400, 400)) is False + + +def test_plan_returns_loop_boxes_and_prefers_loop_face(): + plan = _plan(_fake_mlbox(loop_all=(100, 100, 400, 400), loop_face=(150, 150, 300, 300))) + assert plan is not None + assert plan.loop_all_box == (100, 100, 400, 400) + assert plan.loop_face_box == (150, 150, 300, 300) + assert plan.image_width == 1000 and plan.image_height == 1000 + assert plan.grid_request.n_x >= 1 and plan.grid_request.n_y >= 1 + + +def test_plan_none_when_no_loop(): + # crystal only, no loop -> no grid + assert _plan(_fake_mlbox(crystals=[(300, 300, 350, 350)])) is None + + +def test_loop_face_padding_grows_grid(monkeypatch): + box = (150, 150, 300, 300) + monkeypatch.setattr(mlb, "cfg_get", lambda key, default=None: 0.0 if "padding" in key else default) + unpadded = _plan(_fake_mlbox(loop_face=box)).grid_request + monkeypatch.setattr(mlb, "cfg_get", lambda key, default=None: 0.5 if "padding" in key else default) + padded = _plan(_fake_mlbox(loop_face=box)).grid_request + assert padded.n_x > unpadded.n_x and padded.n_y > unpadded.n_y + + +def test_crystal_union_extends_grid_only_when_enabled(monkeypatch): + # crystal extends well beyond the loop_face box on +x + mlbox = lambda: _fake_mlbox(loop_face=(150, 150, 300, 300), crystals=[(350, 150, 520, 300)]) + + monkeypatch.setattr(mlb, "cfg_get", lambda key, default=None: False if "include_crystal" in key else (0.0 if "padding" in key else default)) + off = _plan(mlbox()).grid_request + + monkeypatch.setattr(mlb, "cfg_get", lambda key, default=None: True if "include_crystal" in key else (0.0 if "padding" in key else default)) + on = _plan(mlbox()).grid_request + + assert on.n_x > off.n_x # grid widened to reach the crystal + assert on.n_y == off.n_y # crystal is within the loop's y-range + + +def test_zoom_box_uses_loop_all_unless_clipped(): + from aare.daq.operations.raster.service import RasterService + + svc = RasterService.__new__(RasterService) + + # loop_all fully inside the frame -> used for zoom + ok = mlb.MLRasterPlan( + grid_request=None, loop_all_box=(100, 100, 400, 400), + loop_face_box=(150, 150, 300, 300), image_width=1000, image_height=1000, + ) + assert svc._zoom_to_fit_box(ok) == (100, 100, 400, 400) + + # loop_all touches the left edge (clipped) -> fall back to loop_face + clipped = mlb.MLRasterPlan( + grid_request=None, loop_all_box=(0, 100, 400, 400), + loop_face_box=(150, 150, 300, 300), image_width=1000, image_height=1000, + ) + assert svc._zoom_to_fit_box(clipped) == (150, 150, 300, 300) + + # no loop_all -> loop_face + only_face = mlb.MLRasterPlan( + grid_request=None, loop_all_box=None, + loop_face_box=(150, 150, 300, 300), image_width=1000, image_height=1000, + ) + assert svc._zoom_to_fit_box(only_face) == (150, 150, 300, 300) From 48111e5718082bf3bc6e903590689918bf1c8ed4 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 26 Jun 2026 09:58:05 +0200 Subject: [PATCH 02/10] gui: always allow pausing automation; only gate starting/resuming The play/pause toggle ran its start-time checks (beamline state, busy, baton, maintenance) before the pause branch, so when running you could be blocked from pausing if the beamline was in the wrong state or busy. Handle the pause toggle first and return immediately (unconditional), and run the start-condition gates only when starting/resuming from paused. Co-Authored-By: Claude Opus 4.8 --- src/aare/gui/panels/sample_queue_panel.py | 146 +++++++++++----------- 1 file changed, 76 insertions(+), 70 deletions(-) diff --git a/src/aare/gui/panels/sample_queue_panel.py b/src/aare/gui/panels/sample_queue_panel.py index ce46a850..629c4cc5 100644 --- a/src/aare/gui/panels/sample_queue_panel.py +++ b/src/aare/gui/panels/sample_queue_panel.py @@ -268,6 +268,20 @@ class SampleQueuePanel(QFrame): def run(self): self.__recovery_timer.stop() + + # Pausing must ALWAYS be possible, regardless of beamline state, busy + # flag, baton or maintenance. Only starting/resuming is gated on + # conditions, so handle the pause toggle first and return immediately. + if not self.__pause: + self.__set_to_pause = True + self.__pause = True + self.table_model.set_running(False) + self.play_button.setText("▶ Run") + self._emit_samples_in_queue_changed() + self.automation_running_changed.emit(False) + return + + # --- Starting/resuming: require the beamline to be in a good state. --- checks_enabled = self._checks_enabled() if checks_enabled: if self.__warning_msg_box: @@ -282,82 +296,74 @@ class SampleQueuePanel(QFrame): ) return - if self.__pause: - if self.__busy: - logger.error(f"Cannot run automation while beamline is busy. Busy flag = {self.__busy}") - self.show_error_dialog( - title="Beamline is busy", - msg="Cannot run automation while beamline is busy", - info="Please wait until beamline is idle " - "or contact your local contact for support", - ) - return + if self.__busy: + logger.error(f"Cannot run automation while beamline is busy. Busy flag = {self.__busy}") + self.show_error_dialog( + title="Beamline is busy", + msg="Cannot run automation while beamline is busy", + info="Please wait until beamline is idle " + "or contact your local contact for support", + ) + return - if self.__baton_holder is SessionsStateEnum.Vacant: - self.show_error_dialog( - title="Session is vacant", - msg="Starting automation while session is vacant is not currently implemented", - info="Please grab the baton before continuing " - "or contact your local contact for support", - ) - return + if self.__baton_holder is SessionsStateEnum.Vacant: + self.show_error_dialog( + title="Session is vacant", + msg="Starting automation while session is vacant is not currently implemented", + info="Please grab the baton before continuing " + "or contact your local contact for support", + ) + return - elif self.__baton_holder is not SessionsStateEnum.OwnedByYou: - self.show_error_dialog( - title="You do not hold the baton", - msg="You do not hold the baton.", - info="Please request the baton if it is your shift." - "If your baton request is denied and it should be the start of your shift," - "please contact your local contact for support", - ) - return + elif self.__baton_holder is not SessionsStateEnum.OwnedByYou: + self.show_error_dialog( + title="You do not hold the baton", + msg="You do not hold the baton.", + info="Please request the baton if it is your shift." + "If your baton request is denied and it should be the start of your shift," + "please contact your local contact for support", + ) + return - if self.__beamline_state is BeamlineStateEnum.Maintenance: - self.show_error_dialog( - title="Maintenance mode", - msg="Cannot run automation while beamline is in maintenance mode", - info=("Change to safe state such as Sample Exchange before trying to continue. " - "If this issue persists please contact your local contact for support."), - ) - return + if self.__beamline_state is BeamlineStateEnum.Maintenance: + self.show_error_dialog( + title="Maintenance mode", + msg="Cannot run automation while beamline is in maintenance mode", + info=("Change to safe state such as Sample Exchange before trying to continue. " + "If this issue persists please contact your local contact for support."), + ) + return - if len(self.table_model.samples) > 0: - if checks_enabled: - bad = self._bad_conditions() - if bad: - logger.warning(f"Cannot start automation; beamline not ready: {bad}") - self.show_error_dialog( - title="Beamline not ready", - msg="Cannot start automation:\n- " + "\n- ".join(bad), - info="Fix the above, or untick 'Pause on bad conditions' for testing.", - ) - return + if len(self.table_model.samples) > 0: + if checks_enabled: + bad = self._bad_conditions() + if bad: + logger.warning(f"Cannot start automation; beamline not ready: {bad}") + self.show_error_dialog( + title="Beamline not ready", + msg="Cannot start automation:\n- " + "\n- ".join(bad), + info="Fix the above, or untick 'Pause on bad conditions' for testing.", + ) + return - self.table_model.set_running(True) - self.__set_to_pause = False - self.__pause = False - self.play_button.setText("⏸ Pause") - current = self.table_model.samples[0] - self._current_db_id = current.db_id - self._emit_samples_in_queue_changed() - self.automation_running_changed.emit(True) - self.auto_scan.emit(current) - self.viewer_track_online.emit() - else: - logger.debug("No samples in queue, skipping") - self.show_error_dialog( - title="No Samples in Queue", - msg="Cannot run automation as there are no samples in the queue.", - info="Please add samples to the queue.", - ) - return - else: - self.__set_to_pause = True - self.__pause = True - self.table_model.set_running(False) - self.play_button.setText("▶ Run") + self.table_model.set_running(True) + self.__set_to_pause = False + self.__pause = False + self.play_button.setText("⏸ Pause") + current = self.table_model.samples[0] + self._current_db_id = current.db_id self._emit_samples_in_queue_changed() - self.automation_running_changed.emit(False) + self.automation_running_changed.emit(True) + self.auto_scan.emit(current) + self.viewer_track_online.emit() + else: + logger.debug("No samples in queue, skipping") + self.show_error_dialog( + title="No Samples in Queue", + msg="Cannot run automation as there are no samples in the queue.", + info="Please add samples to the queue.", + ) + return def clear(self): self.table_model.clearSamples() From 0ac948746ab6fc0367ff9c5db2a22879b6f425f7 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 26 Jun 2026 10:08:18 +0200 Subject: [PATCH 03/10] raster auto-center: make vertical line-scan Y padding configurable The 2nd-stage vertical line scan (n_x=1, height from the ML box) padded its height by a hardcoded 0.10 each side (20% total), which was often too short to cover the whole loop. Read the per-side fraction from daq.auto_raster.line_scan_y_padding_fraction (default 0.15) so it can be tuned per beamline. Co-Authored-By: Claude Opus 4.8 --- src/aare/common/config/x06da.yaml | 1 + src/aare/daq/operations/raster/service.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/aare/common/config/x06da.yaml b/src/aare/common/config/x06da.yaml index 95d8bbaa..fb8136e2 100644 --- a/src/aare/common/config/x06da.yaml +++ b/src/aare/common/config/x06da.yaml @@ -56,3 +56,4 @@ daq: auto_raster: loop_face_padding_fraction: 0.15 # pad loop_face raster boxes by 15% per side include_crystal: false # extend the grid to cover crystals outside the loop box + line_scan_y_padding_fraction: 0.15 # pad the 2nd-stage vertical line scan height by this per side (10-20%) diff --git a/src/aare/daq/operations/raster/service.py b/src/aare/daq/operations/raster/service.py index 2496abd0..a6ae69a9 100644 --- a/src/aare/daq/operations/raster/service.py +++ b/src/aare/daq/operations/raster/service.py @@ -213,7 +213,7 @@ class RasterService: grid_size_mm: Coordinate, default_n_y: int = 50, y_retarget_threshold_mm: float | None = None, - y_padding_fraction_each_side: float = 0.10, + y_padding_fraction_each_side: float = 0.15, ) -> tuple[SmargonCoordinate, int]: geom = self.ctx.sample_geometry beam_x_pxl = geom.beam_location_pxl.x @@ -799,7 +799,9 @@ class RasterService: geom.beam_size_mm.y * 2.0, grid.grid_size_mm.y * 4.0, ), - y_padding_fraction_each_side=0.10, + y_padding_fraction_each_side=float( + cfg_get("daq.auto_raster.line_scan_y_padding_fraction", 0.15) + ), ) self.logger.info( From 5377e7d4897aa557f5c9b4aed1c88536cf86cc79 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 26 Jun 2026 10:43:12 +0200 Subject: [PATCH 04/10] raster auto-center: unified first-grid padding in x and y (shifts top-left) Replace the loop_face-only box padding with a single grid padding applied to the first auto-center grid scan for any loop box (loop_all or loop_face). It pads each side by a fraction of the grid size (daq.auto_raster.grid_padding_ fraction_x / _y, default 0.15), with a minimum of one cell per side, and extends *before* cell 0 too so smargon_top_left shifts outward. Manual grid (get_ml_bounding_box) is unchanged (grid_padding defaults off). Co-Authored-By: Claude Opus 4.8 --- src/aare/common/config/x06da.yaml | 3 +- .../daq/operations/common/ml_bounding_box.py | 34 +++++++++---- .../daq/operations/test_ml_raster_plan.py | 51 +++++++++++++++---- 3 files changed, 67 insertions(+), 21 deletions(-) diff --git a/src/aare/common/config/x06da.yaml b/src/aare/common/config/x06da.yaml index fb8136e2..54c20d77 100644 --- a/src/aare/common/config/x06da.yaml +++ b/src/aare/common/config/x06da.yaml @@ -54,6 +54,7 @@ daq: maximum_flux: 4e11 auto_raster: - loop_face_padding_fraction: 0.15 # pad loop_face raster boxes by 15% per side + grid_padding_fraction_x: 0.15 # pad the 1st grid scan by this fraction of its size per side in x (min 1 cell) + grid_padding_fraction_y: 0.15 # ... and in y; shifts smargon_top_left outward (adds cells before cell 0) include_crystal: false # extend the grid to cover crystals outside the loop box line_scan_y_padding_fraction: 0.15 # pad the 2nd-stage vertical line scan height by this per side (10-20%) diff --git a/src/aare/daq/operations/common/ml_bounding_box.py b/src/aare/daq/operations/common/ml_bounding_box.py index 0428f225..41923fc0 100644 --- a/src/aare/daq/operations/common/ml_bounding_box.py +++ b/src/aare/daq/operations/common/ml_bounding_box.py @@ -188,13 +188,36 @@ def _box_to_raster_request( max_images: int, min_cell_size_mm: float, skip_if_exceed_max_image_threshold: bool, + grid_padding: bool = False, ) -> RasterGridRequest: geom = sample_geometry - start_coord = geom.picture_to_smargon(Coordinate(x=x1, y=y1)) grid_size = Coordinate(x=geom.beam_size_mm.x * 0.8, y=geom.beam_size_mm.y * 0.8) n_x = max(1, abs(ceil((x2 - x1) * geom.pixel_in_mm / grid_size.x))) n_y = max(1, abs(ceil((y2 - y1) * geom.pixel_in_mm / grid_size.y))) + if grid_padding: + # Pad the grid by a fraction of its size on each side (at least one cell), + # extending *before* cell 0 as well, so the top-left moves outward and + # smargon_top_left shifts with it. + frac_x = float(cfg_get("daq.auto_raster.grid_padding_fraction_x", 0.15)) + frac_y = float(cfg_get("daq.auto_raster.grid_padding_fraction_y", 0.15)) + pad_x = max(1, int(ceil(frac_x * n_x))) + pad_y = max(1, int(ceil(frac_y * n_y))) + x1 = x1 - pad_x * grid_size.x / geom.pixel_in_mm + y1 = y1 - pad_y * grid_size.y / geom.pixel_in_mm + n_x = n_x + 2 * pad_x + n_y = n_y + 2 * pad_y + logger.info( + "Padded auto-center raster grid", + extra=merge_log_context( + sample_log_context(sample), + {"sample_id": sample_id, "ml_image_name": filename, + "pad_cells_x": pad_x, "pad_cells_y": pad_y, "n_x": n_x, "n_y": n_y}, + ), + ) + + start_coord = geom.picture_to_smargon(Coordinate(x=x1, y=y1)) + original_n_x = n_x original_n_y = n_y original_grid_size = grid_size @@ -326,14 +349,6 @@ def build_ml_raster_plan( x1, y1, x2, y2 = (grid_model.box.top_x, grid_model.box.top_y, grid_model.box.bottom_x, grid_model.box.bottom_y) - is_loop_face = grid_model is loop_face - - # Pad loop_face boxes a little so the grid isn't cut tight to the face. - if is_loop_face: - frac = float(cfg_get("daq.auto_raster.loop_face_padding_fraction", 0.15)) - pad_x = (x2 - x1) * frac - pad_y = (y2 - y1) * frac - x1, y1, x2, y2 = x1 - pad_x, y1 - pad_y, x2 + pad_x, y2 + pad_y # Optionally extend the grid to cover crystals detected outside the loop box. if cfg_get("daq.auto_raster.include_crystal", False) and predictions is not None: @@ -362,6 +377,7 @@ def build_ml_raster_plan( max_images=max_images, min_cell_size_mm=min_cell_size_mm, skip_if_exceed_max_image_threshold=skip_if_exceed_max_image_threshold, + grid_padding=True, ) image_height = int(bundle_image.shape[0]) if bundle_image is not None else None diff --git a/tests/unit/daq/operations/test_ml_raster_plan.py b/tests/unit/daq/operations/test_ml_raster_plan.py index 72a53ec1..eeef603c 100644 --- a/tests/unit/daq/operations/test_ml_raster_plan.py +++ b/tests/unit/daq/operations/test_ml_raster_plan.py @@ -11,6 +11,7 @@ from aare.daq.mlbox import MLBoxPredictionsResult from aare.daq.operations.common import ml_bounding_box as mlb from aare.daq.operations.common.ml_bounding_box import ( _box_extends_beyond, + _box_to_raster_request, _box_union, build_ml_raster_plan, ) @@ -81,27 +82,55 @@ def test_plan_none_when_no_loop(): assert _plan(_fake_mlbox(crystals=[(300, 300, 350, 350)])) is None -def test_loop_face_padding_grows_grid(monkeypatch): - box = (150, 150, 300, 300) - monkeypatch.setattr(mlb, "cfg_get", lambda key, default=None: 0.0 if "padding" in key else default) - unpadded = _plan(_fake_mlbox(loop_face=box)).grid_request - monkeypatch.setattr(mlb, "cfg_get", lambda key, default=None: 0.5 if "padding" in key else default) - padded = _plan(_fake_mlbox(loop_face=box)).grid_request - assert padded.n_x > unpadded.n_x and padded.n_y > unpadded.n_y +def _grid(box, *, grid_padding): + x1, y1, x2, y2 = box + return _box_to_raster_request( + x1=x1, y1=y1, x2=x2, y2=y2, + sample=None, sample_geometry=_geom(), logger=logger, filename=None, + sample_id=None, max_images=100000, min_cell_size_mm=0.0001, + skip_if_exceed_max_image_threshold=False, grid_padding=grid_padding, + ) + + +def test_grid_padding_grows_and_shifts_top_left(monkeypatch): + # fraction 0 -> minimum one cell of padding per side + monkeypatch.setattr(mlb, "cfg_get", lambda k, d=None: 0.0 if "grid_padding_fraction" in k else d) + box = (150, 150, 400, 300) + nopad = _grid(box, grid_padding=False) + pad = _grid(box, grid_padding=True) + assert pad.n_x == nopad.n_x + 2 # 1 cell each side in x + assert pad.n_y == nopad.n_y + 2 # 1 cell each side in y + # top-left shifted outward (cells added before cell 0) + assert pad.smargon_top_left.sh_mm.x != nopad.smargon_top_left.sh_mm.x + assert pad.smargon_top_left.sh_mm.z != nopad.smargon_top_left.sh_mm.z + + +def test_grid_padding_fraction_scales(monkeypatch): + box = (150, 150, 520, 420) + monkeypatch.setattr(mlb, "cfg_get", lambda k, d=None: 0.0 if "grid_padding_fraction" in k else d) + small = _grid(box, grid_padding=True) + monkeypatch.setattr(mlb, "cfg_get", lambda k, d=None: 0.5 if "grid_padding_fraction" in k else d) + big = _grid(box, grid_padding=True) + assert big.n_x > small.n_x and big.n_y > small.n_y def test_crystal_union_extends_grid_only_when_enabled(monkeypatch): # crystal extends well beyond the loop_face box on +x mlbox = lambda: _fake_mlbox(loop_face=(150, 150, 300, 300), crystals=[(350, 150, 520, 300)]) - monkeypatch.setattr(mlb, "cfg_get", lambda key, default=None: False if "include_crystal" in key else (0.0 if "padding" in key else default)) - off = _plan(mlbox()).grid_request + def cfg(enabled): + return lambda k, d=None: ( + enabled if "include_crystal" in k + else (0.0 if "grid_padding_fraction" in k else d) + ) - monkeypatch.setattr(mlb, "cfg_get", lambda key, default=None: True if "include_crystal" in key else (0.0 if "padding" in key else default)) + monkeypatch.setattr(mlb, "cfg_get", cfg(False)) + off = _plan(mlbox()).grid_request + monkeypatch.setattr(mlb, "cfg_get", cfg(True)) on = _plan(mlbox()).grid_request assert on.n_x > off.n_x # grid widened to reach the crystal - assert on.n_y == off.n_y # crystal is within the loop's y-range + assert on.n_y == off.n_y # crystal is within the loop's y-range (same padding both) def test_zoom_box_uses_loop_all_unless_clipped(): From 88d17d6b33fda65f03ba4da44c87ac683b7d7a0f Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 26 Jun 2026 10:50:56 +0200 Subject: [PATCH 05/10] status_bar: shutter renamed fast shutter --- src/aare/gui/widgets/status_bar.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index 75a0e6f4..d6a1890f 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -162,9 +162,9 @@ class StatusBar(QStatusBar): self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", "red") if status.bl.shutter_open: - self.shutter_label.setText(f"""Shutter: Open ☢️ """) + self.shutter_label.setText(f"""Fast Shutter: Open ☢️ """) else: - self.shutter_label.setText(f"""Shutter: Closed 🚪 """) + self.shutter_label.setText(f"""Fast Shutter: Closed 🚪 """) if status.bl.exp_shutter_open: self.exp_shutter_label.setText("""ExpHutch Shutter: Open """) From 6420709114c5dd8d0d62b287a5b74af354854c3c Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 26 Jun 2026 10:52:29 +0200 Subject: [PATCH 06/10] Smargon_panel: Russell added z-axis smargon buttons to the GUI, already tested! --- src/aare/gui/panels/smargon_panel.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/aare/gui/panels/smargon_panel.py b/src/aare/gui/panels/smargon_panel.py index c9b1f5f6..d9b4849f 100644 --- a/src/aare/gui/panels/smargon_panel.py +++ b/src/aare/gui/panels/smargon_panel.py @@ -20,10 +20,10 @@ class SmargonMoveWidget(QWidget): grid_layout.setColumnStretch(1, 1) grid_layout.setColumnStretch(2, 1) - self.button_left = ButtonWithPayload("←", payload={"x": 1, "y": 0}) - self.button_right = ButtonWithPayload("→", payload={"x": -1, "y": 0}) - self.button_up = ButtonWithPayload("↑", payload={"x": 0, "y": 1}) - self.button_down = ButtonWithPayload("↓", payload={"x": 0, "y": -1}) + self.button_left = ButtonWithPayload("←", payload={"x": 1, "y": 0, "z": 0}) + self.button_right = ButtonWithPayload("→", payload={"x": -1, "y": 0, "z": 0}) + self.button_up = ButtonWithPayload("↑", payload={"x": 0, "y": 1, "z": 0}) + self.button_down = ButtonWithPayload("↓", payload={"x": 0, "y": -1, "z": 0}) self.button_left.pressed.connect(self.smargon_button) self.button_right.pressed.connect(self.smargon_button) @@ -35,9 +35,19 @@ class SmargonMoveWidget(QWidget): grid_layout.addWidget(self.button_right, 1, 2) grid_layout.addWidget(self.button_down, 2, 1) + self.button_in = ButtonWithPayload("+", payload={"x": 0, "y": 0, "z": 1}) + self.button_out = ButtonWithPayload("-", payload={"x": 0, "y": 0, "z": -1}) + + self.button_in.pressed.connect(self.smargon_button) + self.button_out.pressed.connect(self.smargon_button) + grid_layout.addWidget(self.button_in, 3, 2) + grid_layout.addWidget(self.button_out, 3, 0) + + + @Slot(dict) def smargon_button(self, payload: dict): - self.smargon_rel.emit(Coordinate(x=payload["x"], y=payload["y"])) + self.smargon_rel.emit(Coordinate(x=payload["x"], y=payload["y"],z=payload["z"])) class SmargonPanel(QWidget): @@ -73,6 +83,7 @@ class SmargonPanel(QWidget): grid_layout.addWidget(self.move_panel, 3, 0, 1, 6) self.move_panel.smargon_rel.connect(self.smargon_rel) + grid_layout.addWidget(QLabel("Step", parent=self), 4, 0) self.step = NumberLineEdit(1, 1000, 100, 0, parent=self) grid_layout.addWidget(self.step, 4, 1) From b8dd3756ccb863c08b086311a45d25c47d7ef831 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 26 Jun 2026 11:08:02 +0200 Subject: [PATCH 07/10] raster auto-center: allow extra grid padding at the bottom of the y scan Make the first-grid y padding asymmetric: the bottom (far end of the n_y scan) can be padded more than the top via daq.auto_raster.grid_padding_fraction_y_ bottom (defaults to grid_padding_fraction_y, so symmetric unless set higher). The top padding still shifts smargon_top_left; the extra bottom padding only grows n_y. Co-Authored-By: Claude Opus 4.8 --- .../daq/operations/common/ml_bounding_box.py | 16 +++++++++------ .../daq/operations/test_ml_raster_plan.py | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/aare/daq/operations/common/ml_bounding_box.py b/src/aare/daq/operations/common/ml_bounding_box.py index 41923fc0..7b42742c 100644 --- a/src/aare/daq/operations/common/ml_bounding_box.py +++ b/src/aare/daq/operations/common/ml_bounding_box.py @@ -198,21 +198,25 @@ def _box_to_raster_request( if grid_padding: # Pad the grid by a fraction of its size on each side (at least one cell), # extending *before* cell 0 as well, so the top-left moves outward and - # smargon_top_left shifts with it. + # smargon_top_left shifts with it. Y is asymmetric: the bottom (far end + # of the n_y scan) can be padded more than the top. frac_x = float(cfg_get("daq.auto_raster.grid_padding_fraction_x", 0.15)) - frac_y = float(cfg_get("daq.auto_raster.grid_padding_fraction_y", 0.15)) + frac_y_top = float(cfg_get("daq.auto_raster.grid_padding_fraction_y", 0.15)) + frac_y_bottom = float(cfg_get("daq.auto_raster.grid_padding_fraction_y_bottom", frac_y_top)) pad_x = max(1, int(ceil(frac_x * n_x))) - pad_y = max(1, int(ceil(frac_y * n_y))) + pad_y_top = max(1, int(ceil(frac_y_top * n_y))) + pad_y_bottom = max(1, int(ceil(frac_y_bottom * n_y))) x1 = x1 - pad_x * grid_size.x / geom.pixel_in_mm - y1 = y1 - pad_y * grid_size.y / geom.pixel_in_mm + y1 = y1 - pad_y_top * grid_size.y / geom.pixel_in_mm n_x = n_x + 2 * pad_x - n_y = n_y + 2 * pad_y + n_y = n_y + pad_y_top + pad_y_bottom logger.info( "Padded auto-center raster grid", extra=merge_log_context( sample_log_context(sample), {"sample_id": sample_id, "ml_image_name": filename, - "pad_cells_x": pad_x, "pad_cells_y": pad_y, "n_x": n_x, "n_y": n_y}, + "pad_cells_x": pad_x, "pad_cells_y_top": pad_y_top, + "pad_cells_y_bottom": pad_y_bottom, "n_x": n_x, "n_y": n_y}, ), ) diff --git a/tests/unit/daq/operations/test_ml_raster_plan.py b/tests/unit/daq/operations/test_ml_raster_plan.py index eeef603c..9a93c749 100644 --- a/tests/unit/daq/operations/test_ml_raster_plan.py +++ b/tests/unit/daq/operations/test_ml_raster_plan.py @@ -105,6 +105,26 @@ def test_grid_padding_grows_and_shifts_top_left(monkeypatch): assert pad.smargon_top_left.sh_mm.z != nopad.smargon_top_left.sh_mm.z +def test_grid_padding_y_bottom_asymmetric(monkeypatch): + box = (150, 150, 400, 450) # tall box + + def cfg(y_bottom): + return lambda k, d=None: ( + y_bottom if "grid_padding_fraction_y_bottom" in k + else (0.0 if "grid_padding_fraction" in k else d) + ) + + monkeypatch.setattr(mlb, "cfg_get", cfg(0.0)) # bottom == top (min 1 cell each) + sym = _grid(box, grid_padding=True) + monkeypatch.setattr(mlb, "cfg_get", cfg(0.6)) # much more padding at the bottom + bottom = _grid(box, grid_padding=True) + + assert bottom.n_y > sym.n_y # extra cells added at the bottom + assert bottom.n_x == sym.n_x # x unaffected + # top padding identical -> smargon_top_left (cell 0) unchanged + assert bottom.smargon_top_left == sym.smargon_top_left + + def test_grid_padding_fraction_scales(monkeypatch): box = (150, 150, 520, 420) monkeypatch.setattr(mlb, "cfg_get", lambda k, d=None: 0.0 if "grid_padding_fraction" in k else d) From 929b9aee8d3ccc967efbe768fe1497163fed5eb1 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 26 Jun 2026 11:19:10 +0200 Subject: [PATCH 08/10] Raster_grid_manager: renabled load from file --- src/aare/gui/scan_logic/raster_grid_manager.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/aare/gui/scan_logic/raster_grid_manager.py b/src/aare/gui/scan_logic/raster_grid_manager.py index ca1503da..fc287e13 100644 --- a/src/aare/gui/scan_logic/raster_grid_manager.py +++ b/src/aare/gui/scan_logic/raster_grid_manager.py @@ -328,8 +328,11 @@ class RasterGridManager(QObject): self.__loaded_image_prefix = grid.result.file_prefix self.__loaded_image_index = grid.result.images[cell].number logger.debug(f"Load {grid.result.file_prefix} {grid.result.images[cell].number}") - #self.image_selected.emit(grid.result.file_prefix, grid.result.images[cell].number) - self.image_selected.emit(self.__detector_url, grid.result.images[cell].number) + self.image_selected.emit(grid.result.file_prefix, grid.result.images[cell].number) + #TODO if not in the same PGroup, user can stream from last run only but never load from a file. + #If loading a prior run, this shoudl throw an error, + #if user in same pgroup, load will always be fine. + #self.image_selected.emit(self.__detector_url, grid.result.images[cell].number) def is_part_of_active_grid(self, point: QPointF) -> bool: if not self._is_grid_visible(self.__active_grid): From f33aa8c91ad8d96ae9bf6c6a26437f295bcbd2ea Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 26 Jun 2026 11:19:18 +0200 Subject: [PATCH 09/10] yaml update --- src/aare/common/config/x06da.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/aare/common/config/x06da.yaml b/src/aare/common/config/x06da.yaml index 54c20d77..43aaa2e9 100644 --- a/src/aare/common/config/x06da.yaml +++ b/src/aare/common/config/x06da.yaml @@ -32,6 +32,9 @@ daq: default_detector_distance_maximum: 900 zoom_min: 1 # camera zoom travel limits (used to clamp auto-center zoom-to-fit) zoom_max: 1000 + sam_cam: + #TODO wire this + white_balance_ratio = 1.233 db: aaredb_url: "https://mx-aaredb-dmz-01.psi.ch/dispatcher" @@ -54,7 +57,8 @@ daq: maximum_flux: 4e11 auto_raster: - grid_padding_fraction_x: 0.15 # pad the 1st grid scan by this fraction of its size per side in x (min 1 cell) - grid_padding_fraction_y: 0.15 # ... and in y; shifts smargon_top_left outward (adds cells before cell 0) - include_crystal: false # extend the grid to cover crystals outside the loop box + grid_padding_fraction_x: 0.15 # pad the 1st grid scan by this fraction of its size per side in x (min 1 cell) + grid_padding_fraction_y: 0.15 # ... and the TOP in y; shifts smargon_top_left outward (cells before cell 0) + grid_padding_fraction_y_bottom: 0.30 # pad the BOTTOM of the grid (far end of n_y) more; defaults to grid_padding_fraction_y + include_crystal: false # extend the grid to cover crystals outside the loop box line_scan_y_padding_fraction: 0.15 # pad the 2nd-stage vertical line scan height by this per side (10-20%) From ad44acabc025ced4667da626c146ce219d511606 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 26 Jun 2026 16:37:16 +0200 Subject: [PATCH 10/10] DAQ: have the get xtal size, best res, get best b factor added --- src/aare/daq/operations/raster/service.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/aare/daq/operations/raster/service.py b/src/aare/daq/operations/raster/service.py index a6ae69a9..a2017206 100644 --- a/src/aare/daq/operations/raster/service.py +++ b/src/aare/daq/operations/raster/service.py @@ -8,7 +8,8 @@ from jfjoch_client.exceptions import NotFoundException from aare.common.coordinate import AerotechCoordinate, Coordinate, SmargonCoordinate from aare.common.exception_handler import AutoRasterSampleSkipped, RasterScanException -from aare.common.find_xtal import create_quality_filtered_array, raster_centre_of_mass +from aare.common.find_xtal import create_quality_filtered_array, raster_centre_of_mass, get_xtal_size, get_best_res, \ + get_best_b_factor from aare.common.logger_events import ( geom_log_context, log_ml_bundle_meta, @@ -629,7 +630,9 @@ class RasterService: }, ), ) - + self.ctx.deps.cfg.crystal_size = get_xtal_size(self.ctx.deps.cfg.crystal_size, result_array, r=request) + self.ctx.deps.cfg.last_best_res = get_best_res(result_list= scan_result.images) + self.ctx.deps.cfg.last_best_b_factor = get_best_b_factor(result_list=scan_result.images) return CompletedRasterGridElem( request=copy.deepcopy(request), result=scan_result,