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)