Merge branch 'ml_zoom'
Build and Publish / test (push) Successful in 2m12s
Build and Publish / build (push) Failing after 17s
Build and Publish / Build and Deploy Docs (push) Skipped

# Conflicts:
#	src/aare/daq/operations/raster/service.py
This commit is contained in:
appleb_m
2026-06-26 16:45:20 +02:00
9 changed files with 602 additions and 91 deletions
+12
View File
@@ -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%)
+22
View File
@@ -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")
@@ -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,
)
+104 -9
View File
@@ -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(
+76 -70
View File
@@ -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()
+16 -5
View File
@@ -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)
@@ -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):
+2 -2
View File
@@ -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: <span style="color: red ; "> Open ☢️ </span>""")
self.shutter_label.setText(f"""Fast Shutter: <span style="color: red ; "> Open ☢️ </span>""")
else:
self.shutter_label.setText(f"""Shutter: <span style="color: green ; "> Closed 🚪 </span>""")
self.shutter_label.setText(f"""Fast Shutter: <span style="color: green ; "> Closed 🚪 </span>""")
if status.bl.exp_shutter_open:
self.exp_shutter_label.setText("""ExpHutch Shutter: <span style="color: red ; "> Open </span>""")
@@ -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)