diff --git a/src/aare/common/config/x06da.yaml b/src/aare/common/config/x06da.yaml
index 2bf17cd8..43aaa2e9 100644
--- a/src/aare/common/config/x06da.yaml
+++ b/src/aare/common/config/x06da.yaml
@@ -30,6 +30,11 @@ 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
+ sam_cam:
+ #TODO wire this
+ white_balance_ratio = 1.233
db:
aaredb_url: "https://mx-aaredb-dmz-01.psi.ch/dispatcher"
@@ -50,3 +55,10 @@ daq:
detector_limit_modifier: 2.0
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 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%)
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..7b42742c 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,11 +164,64 @@ def get_ml_bounding_box(
),
)
- start_coord = geom.picture_to_smargon(Coordinate(x=x1, y=y1))
+ 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,
+ grid_padding: bool = False,
+) -> RasterGridRequest:
+ geom = sample_geometry
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. 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_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_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_top * grid_size.y / geom.pixel_in_mm
+ n_x = n_x + 2 * pad_x
+ 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_top": pad_y_top,
+ "pad_cells_y_bottom": pad_y_bottom, "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
@@ -211,4 +295,102 @@ 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)
+
+ # 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,
+ grid_padding=True,
+ )
+
+ 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 0c361ef1..2cec37c7 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 raster_highest_score
+from aare.common.find_xtal import raster_highest_score, get_xtal_size, get_best_res, \
+ get_best_b_factor
from aare.common.logger_events import (
geom_log_context,
log_ml_bundle_meta,
@@ -16,12 +17,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 +127,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,
*,
@@ -128,7 +214,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
@@ -527,7 +613,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,
@@ -581,9 +669,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(
@@ -596,9 +684,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(
@@ -612,6 +700,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(
@@ -692,7 +785,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(
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()
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)
diff --git a/src/aare/gui/scan_logic/raster_grid_manager.py b/src/aare/gui/scan_logic/raster_grid_manager.py
index 122e6abf..19db4913 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):
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 """)
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..9a93c749
--- /dev/null
+++ b/tests/unit/daq/operations/test_ml_raster_plan.py
@@ -0,0 +1,180 @@
+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_to_raster_request,
+ _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 _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_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)
+ 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)])
+
+ 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", 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 (same padding both)
+
+
+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)