Merge branch 'ml_zoom'
# Conflicts: # src/aare/daq/operations/raster/service.py
This commit is contained in:
@@ -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%)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user