2208 lines
88 KiB
Python
2208 lines
88 KiB
Python
import copy
|
|
import secrets
|
|
import time
|
|
import traceback
|
|
|
|
from datetime import datetime
|
|
from math import ceil
|
|
from pathlib import Path
|
|
from typing import List, Tuple, Optional, Callable
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
from jfjoch_client import ScanResult, ScanResultImagesInner
|
|
|
|
import aare.common.face_detection as fd
|
|
from aare.daq import workflows
|
|
from aare.daq.aaredb import AareWrapper
|
|
from aare.common.autofocus_tools import focus_measure_edges
|
|
from aare.daq.config import BeamlineConfig, ABR_POS_MOUNT, ABR_OMEGA_MOUNT
|
|
from aare.daq.config import BeamlineStateEnum
|
|
from aare.daq.devices import BeamlineDevices
|
|
from aare.daq.mlbox import MlBox
|
|
from aare.common.beamline import MXBeamline
|
|
from aare.common.coordinate import Coordinate, SmargonCoordinate, AerotechCoordinate
|
|
from aare.common.diffraction_geometry import DiffractionGeometry
|
|
from aare.common.logger_config import setup_logger
|
|
from aare.common.models import (
|
|
SampleShortInfo,
|
|
PuckLoadedInfo,
|
|
SampleShortInfoList, AutofocusSettings,
|
|
DAQStatusModel, BeamlineStatus, SessionStatus, SampleCameraSettings, ZoomModeEnum,
|
|
SimpleScanParameters, MLBoxModel, FluorescenceSpectrumParameterModel,
|
|
FluorescenceSpectrumOutputModel)
|
|
from aare.common.raster_grid import RasterGridRequest, CompletedRasterGrid, CompletedRasterGridElem
|
|
from aare.common.rotation_scan import RotationScanRequest, CompletedRotationScan
|
|
from aare.common.sample_geometry import SampleGeometryModel
|
|
from aare.daq.spreadsheetupdater import beamline
|
|
from aare.devices.area_detector import AutoEnum
|
|
from aare.devices.jfjoch import JFJochWrapper
|
|
from aare.devices.mx_lib import clean_filename
|
|
|
|
from aare.common.exception_handler import (
|
|
TransformationInvalidException,
|
|
LoopCenteringFailed,
|
|
MountingFailed,
|
|
WarningTellException,
|
|
CriticalTellException,
|
|
AXCFailed,
|
|
SmargonCommunicationError,
|
|
TellCommunicationError,
|
|
JFJochCommunicationError, AerotechCommunicationError, MagnetPositionSensorErorr
|
|
)
|
|
|
|
logger = setup_logger("aareDAQ")
|
|
|
|
class AareDAQ:
|
|
|
|
MIN_SPOTS_LOW_RES_THRESHOLD = 10.0
|
|
|
|
def __init__(self, cfg: BeamlineConfig, bl: MXBeamline):
|
|
self.last_time = 0.0
|
|
self.__cfg = cfg
|
|
self.__devs = BeamlineDevices(bl)
|
|
self.__mlbox = MlBox(bl)
|
|
self.__jfjoch = JFJochWrapper(bl)
|
|
self.__bl = bl.value.upper()
|
|
self.__aare = AareWrapper(bl)
|
|
self.__saved_box = None
|
|
self._smargon_trace_path = Path("/sls/mx/applications/logs") / "smargon_trace.csv"
|
|
self._face_detection_progress_cb: Callable[[dict], None] | None = None
|
|
self._last_sample_sync_ts = 0.0
|
|
self._sample_sync_min_interval_s = 2.0
|
|
|
|
def set_face_detection_progress_callback(self, cb: Callable[[dict], None] | None) -> None:
|
|
self._face_detection_progress_cb = cb
|
|
|
|
def _emit_face_detection_progress(self, payload: dict) -> None:
|
|
if self._face_detection_progress_cb is None:
|
|
return
|
|
try:
|
|
self._face_detection_progress_cb(payload)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to emit face detection progress: {e}")
|
|
|
|
def _handle_operation_error(self, operation_name: str, sample: SampleShortInfo | None, error: Exception,
|
|
error_type: str = "generic") -> None:
|
|
"""
|
|
Centralized error handling for all operations.
|
|
|
|
Args:
|
|
operation_name: Name of the operation that failed (e.g., "mount", "alc", "raster")
|
|
sample: The sample being processed
|
|
error: The exception that occurred
|
|
error_type: Type of error - "mount", "alc", "axc", or "generic"
|
|
"""
|
|
if sample is None:
|
|
logger.error(f"Error in {operation_name}: {error}")
|
|
return
|
|
|
|
try:
|
|
if error_type == "mount":
|
|
self.__aare.sample_failed(sample, failed_comment=f"Mount failed: {error}")
|
|
elif error_type == "alc":
|
|
self.__aare.alc_failed(sample, alc_comment=f"Loop centering failed: {error}")
|
|
elif error_type == "axc":
|
|
self.__aare.axc_failed(sample)
|
|
else:
|
|
self.__aare.sample_failed(sample, failed_comment=f"Error in {operation_name}: {error}")
|
|
except Exception as db_error:
|
|
logger.error(f"Failed to report {operation_name} error to database: {db_error}")
|
|
|
|
def _execute_mount_and_prepare(self, sample: SampleShortInfo) -> bool:
|
|
"""
|
|
Execute mounting and take screenshot.
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
self.__mount(sample)
|
|
if sample.db_id is not None:
|
|
self.__aare.sample_mounted(sample)
|
|
self.save_screenshot_db(sample.db_id, f"{sample.db_id}_mounted")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Mount failed: {e}")
|
|
self._handle_operation_error("mount", sample, e, error_type="mount")
|
|
return False
|
|
|
|
def _execute_loop_centering(self, sample_id: int | None) -> bool:
|
|
"""
|
|
Execute loop centering sequence.
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
if not self.__loop_center_sequence(sample_id):
|
|
self._handle_operation_error("loop_centering", self.sample, LoopCenteringFailed(), error_type="alc")
|
|
return False
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Loop centering failed: {e}")
|
|
self._handle_operation_error("loop_centering", self.sample, e, error_type="alc")
|
|
return False
|
|
|
|
def _execute_raster_sequence(self, grid_request: RasterGridRequest,
|
|
auto_center: bool = False) -> CompletedRasterGrid | None:
|
|
"""
|
|
Execute raster scan with optional auto-centering.
|
|
|
|
Args:
|
|
grid_request: Raster grid parameters
|
|
auto_center: If True, use auto-centering; if False, use direct raster
|
|
|
|
Returns:
|
|
CompletedRasterGrid result or None if failed
|
|
"""
|
|
try:
|
|
if auto_center:
|
|
result = self.__auto_center(grid_request)
|
|
else:
|
|
raster_result = self.__raster(grid_request)
|
|
result = CompletedRasterGrid(r=[raster_result])
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"Raster sequence failed: {e}")
|
|
self._handle_operation_error("raster", self.sample, e, error_type="axc")
|
|
return None
|
|
|
|
def _execute_rotation_sequence(self, rotation_request: RotationScanRequest) -> CompletedRotationScan | None:
|
|
"""
|
|
Execute rotation scan.
|
|
|
|
Args:
|
|
rotation_request: Rotation scan parameters
|
|
|
|
Returns:
|
|
CompletedRotationScan result or None if failed
|
|
"""
|
|
try:
|
|
result = self.__rotation(rotation_request)
|
|
self.__set_state(BeamlineStateEnum.SampleAlignment)
|
|
if self.sample is not None and self.sample.db_id is not None:
|
|
self.save_screenshot_db(self.sample.db_id, "scan_preview")
|
|
self.__aare.sample_collected(self.sample)
|
|
self.__aare.ingest_scan(sample=self.sample, result=result.result,
|
|
geom=self.sample_geometry, beam_mark_pxl=self.__cfg.get_beam_mark(self.zoom))
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"Rotation sequence failed: {e}")
|
|
self._handle_operation_error("rotation", self.sample, e, error_type="generic")
|
|
return None
|
|
|
|
def _append_smargon_trace(self, *, sample_id: int | None, event: str) -> None:
|
|
try:
|
|
path = self._smargon_trace_path
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
is_new_file = not path.exists() or path.stat().st_size == 0
|
|
pos = self.smargon
|
|
sh = pos.sh_mm
|
|
|
|
with path.open("a", encoding="utf-8", buffering=1) as f:
|
|
if is_new_file:
|
|
f.write(
|
|
"timestamp,event,sample_id,omega_deg,zoom,"
|
|
"shx_mm,shy_mm,shz_mm,phi_deg,chi_deg\n"
|
|
)
|
|
|
|
f.write(
|
|
f"{datetime.now().isoformat(timespec='milliseconds')},"
|
|
f"{event},"
|
|
f"{'' if sample_id is None else sample_id},"
|
|
f"{self.omega:.3f},"
|
|
f"{self.zoom:.3f},"
|
|
f"{sh.x:.5f},"
|
|
f"{sh.y:.5f},"
|
|
f"{sh.z:.5f},"
|
|
f"{pos.phi_deg:.5f},"
|
|
f"{pos.chi_deg:.5f}\n"
|
|
)
|
|
f.flush()
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to append smargon trace: {e}")
|
|
|
|
def _sample_matches_mounted_address(
|
|
self,
|
|
sample: SampleShortInfo | None,
|
|
mounted_address,
|
|
) -> bool:
|
|
if sample is None or sample.location is None or mounted_address is None:
|
|
return False
|
|
return (
|
|
sample.location.segment == mounted_address.puck.segment
|
|
and sample.location.pos == mounted_address.puck.pos
|
|
and sample.pin == mounted_address.pin
|
|
)
|
|
|
|
def _find_sample_by_mounted_address(self, mounted_address) -> SampleShortInfo | None:
|
|
for sample in self.__cfg.spreadsheet.s:
|
|
if self._sample_matches_mounted_address(sample, mounted_address):
|
|
return sample
|
|
|
|
for sample in self.__cfg.reference_tools.s:
|
|
if self._sample_matches_mounted_address(sample, mounted_address):
|
|
return sample
|
|
|
|
return None
|
|
|
|
def _placeholder_sample_from_mounted_address(self, mounted_address) -> SampleShortInfo:
|
|
return SampleShortInfo(
|
|
db_id=-1,
|
|
puck_name="",
|
|
dewar_name="",
|
|
sample_name=f"Mounted sample {mounted_address.puck.segment}{mounted_address.puck.pos}-{mounted_address.pin}",
|
|
run_number=0,
|
|
user="",
|
|
pin=mounted_address.pin,
|
|
location=mounted_address.puck,
|
|
)
|
|
|
|
def sync_current_sample_from_tell(self, force: bool = False) -> SampleShortInfo | None:
|
|
current_sample = self.__cfg.current_sample
|
|
|
|
if current_sample is not None and current_sample.location is None:
|
|
return current_sample
|
|
|
|
now = time.monotonic()
|
|
if not force and (now - self._last_sample_sync_ts) < self._sample_sync_min_interval_s:
|
|
return current_sample
|
|
|
|
self._last_sample_sync_ts = now
|
|
mounted_address = self.__devs.tell.get_mounted_sample()
|
|
|
|
if mounted_address is None:
|
|
if current_sample is not None and current_sample.location is not None:
|
|
logger.warning("TELL reports no mounted sample; clearing cached current_sample")
|
|
self.__cfg.current_sample = None
|
|
return self.__cfg.current_sample
|
|
|
|
if self._sample_matches_mounted_address(current_sample, mounted_address):
|
|
return current_sample
|
|
|
|
resolved_sample = self._find_sample_by_mounted_address(mounted_address)
|
|
if resolved_sample is None:
|
|
resolved_sample = self._placeholder_sample_from_mounted_address(mounted_address)
|
|
logger.warning(
|
|
"Mounted sample from TELL was not found in known sample lists; using placeholder",
|
|
extra={"mounted_address": str(mounted_address)},
|
|
)
|
|
else:
|
|
logger.info(
|
|
f"Reconciled cached sample from TELL to {resolved_sample.sample_name}",
|
|
extra={"db_id": resolved_sample.db_id},
|
|
)
|
|
|
|
self.__cfg.current_sample = resolved_sample
|
|
return resolved_sample
|
|
|
|
@property
|
|
def state(self) -> BeamlineStateEnum:
|
|
return self.__cfg.state
|
|
|
|
@property
|
|
def busy(self) -> bool:
|
|
return self.__cfg.state_busy
|
|
|
|
@state.setter
|
|
def state(self, target: BeamlineStateEnum):
|
|
if target == BeamlineStateEnum.Moving:
|
|
logger.error(f"Cannot explicitly move to busy state", extra={"target":target, "state":self.__cfg.state})
|
|
raise Exception("Cannot explicitly move to busy state")
|
|
|
|
start = time.perf_counter()
|
|
self.__cfg.try_set_busy(timeout=300)
|
|
self.__set_state(target)
|
|
self.__cfg.state_busy = False
|
|
|
|
end = time.perf_counter()
|
|
|
|
self.last_time = end - start
|
|
|
|
def spreadsheet_params(self) -> tuple[Optional[SimpleScanParameters], str|None]:
|
|
file_prefix = None
|
|
|
|
if self.status.sample is None:
|
|
return None, file_prefix
|
|
|
|
logger.debug(f"generate params for sample: {self.status.sample}")
|
|
aaredb_params = self.status.sample.aaredb_params if hasattr(self.status.sample, "aaredb_params") else None
|
|
if aaredb_params is None:
|
|
return None, file_prefix
|
|
|
|
#if aaredb_params.directory:
|
|
# file_prefix = aaredb_params.directory
|
|
|
|
if (
|
|
getattr(aaredb_params, 'exposure', None) is None
|
|
and getattr(aaredb_params, 'transmission', None) is None
|
|
and getattr(aaredb_params, 'oscillation', None) is None
|
|
and getattr(aaredb_params, 'totalrange', None) is None
|
|
and getattr(aaredb_params, 'targetresolution', None) is None
|
|
):
|
|
return None, file_prefix
|
|
|
|
params = SimpleScanParameters()
|
|
|
|
if (exp := getattr(aaredb_params, 'exposure', None)) is not None:
|
|
params.exp_time_s = exp
|
|
|
|
if (trans := getattr(aaredb_params, 'transmission', None)) is not None:
|
|
logger.debug(f"transmission: {trans}")
|
|
params.transmission = trans / 100.0 if trans > 1.0 else trans
|
|
|
|
if (res := getattr(aaredb_params, 'targetresolution', None)) is not None:
|
|
logger.debug(f"resolution: {res}")
|
|
logger.debug(f"requested dtz: {self.diffraction_geometry.calc_dtz_mm(res)} ")
|
|
new_res = 1 / ((1 / res) + 0.1)
|
|
corrected_dtz = self.diffraction_geometry.calc_dtz_mm(new_res)
|
|
logger.debug(f"corrected dtz: {corrected_dtz}")
|
|
if corrected_dtz < 108:
|
|
corrected_dtz = 108
|
|
params.dtz = round(corrected_dtz)
|
|
|
|
osc = getattr(aaredb_params, 'oscillation', None)
|
|
total = getattr(aaredb_params, 'totalrange', None)
|
|
|
|
if osc is not None:
|
|
osc = abs(osc)
|
|
if osc > 0:
|
|
params.incr_omega_deg = osc
|
|
if total is not None and abs(total) > 0:
|
|
params.steps = round(abs(total) / osc)
|
|
else:
|
|
params.steps = round(360.0 / osc)
|
|
elif total is not None and abs(total) > 0:
|
|
default_osc = SimpleScanParameters().incr_omega_deg
|
|
params.incr_omega_deg = default_osc
|
|
params.steps = round(abs(total) / default_osc)
|
|
|
|
#if file_prefix is not None:
|
|
# params.file_prefix = file_prefix
|
|
|
|
return params, file_prefix
|
|
|
|
|
|
@property
|
|
def omega(self) -> float:
|
|
return self.__devs.aerotech_omega
|
|
|
|
def __omega(self, val: float):
|
|
self.__saved_box = None
|
|
print(f"Set omega to {val}")
|
|
if -2000 < val < 2000:
|
|
try:
|
|
self.__devs.aerotech_omega = val
|
|
self.__cfg.state_busy = False
|
|
except Exception as e:
|
|
logger.error(f"Omega error: {e}")
|
|
self.__cfg.state_busy = False
|
|
logger.error("Omega move timed out")
|
|
else:
|
|
self.__cfg.state_busy = False
|
|
logger.error("Omega has to be between -2000 and 2000 degrees")
|
|
raise ValueError("Omega has to be between -2000 and 2000 degrees (for now)")
|
|
|
|
@omega.setter
|
|
def omega(self, val: float):
|
|
self.__cfg.set_busy(BeamlineStateEnum.SampleAlignment)
|
|
self.__omega(val)
|
|
|
|
def omega_rel(self, val: float):
|
|
self.__cfg.set_busy(BeamlineStateEnum.SampleAlignment)
|
|
curr_omega = self.__devs.aerotech_omega
|
|
self.__omega(curr_omega + val)
|
|
|
|
@property
|
|
def zoom(self) -> float:
|
|
return self.__devs.zoom
|
|
|
|
@zoom.setter
|
|
def zoom(self, val: float):
|
|
self.__saved_box = None
|
|
self.__devs.samcam_auto(AutoEnum.AUTO)
|
|
self.__devs.zoom = val
|
|
time.sleep(0.2)
|
|
self.__devs.samcam_auto(AutoEnum.ONCE)
|
|
|
|
@property
|
|
def front_light(self) -> float:
|
|
val = self.__devs.lamp_light
|
|
if val <= 1.0:
|
|
return 0
|
|
elif val >= 2.5:
|
|
return 100.0
|
|
else:
|
|
return (val - 1.0) / 1.5 * 100.0
|
|
|
|
@front_light.setter
|
|
def front_light(self, f: float):
|
|
conv = (f / 100.0 * 1.5) + 1.0
|
|
print(f"Light {f} -> {conv}")
|
|
self.__devs.lamp_light = conv
|
|
|
|
@property
|
|
def back_light(self) -> float:
|
|
val = self.__devs.back_light
|
|
if val <= 0.0:
|
|
return 0.0
|
|
elif val <= 3.0:
|
|
return val / 3.0 * 100.0
|
|
else:
|
|
return 100.0
|
|
|
|
@back_light.setter
|
|
def back_light(self, f: float):
|
|
conv = f / 100.0 * 3.0
|
|
print(f"Backlight {f} -> {conv}")
|
|
self.__devs.back_light = conv
|
|
|
|
@property
|
|
def sample(self) -> SampleShortInfo | None:
|
|
return self.__cfg.current_sample
|
|
|
|
@property
|
|
def samcam_settings(self) -> SampleCameraSettings:
|
|
return self.__devs.samcam_settings
|
|
|
|
@samcam_settings.setter
|
|
def samcam_settings(self, s: SampleCameraSettings):
|
|
self.__devs.samcam_settings = s
|
|
|
|
def tweak_abr_meas_pos(self, c: AerotechCoordinate):
|
|
self.__cfg.set_busy(BeamlineStateEnum.SampleAlignment)
|
|
try:
|
|
new_meas_pos = AerotechCoordinate(at_mm=self.__cfg.abr_meas_pos.at_mm + c.at_mm)
|
|
self.__cfg.abr_meas_pos = new_meas_pos
|
|
self.__devs.aerotech_pos = new_meas_pos
|
|
self.__saved_box = None
|
|
self.__cfg.state_busy = False
|
|
except Exception:
|
|
self.__cfg.state_busy = False
|
|
raise
|
|
|
|
def save_abr_meas_pos(self):
|
|
self.__cfg.set_busy(BeamlineStateEnum.SampleAlignment)
|
|
try:
|
|
self.__cfg.abr_meas_pos = self.__devs.aerotech_pos
|
|
self.__cfg.state_busy = False
|
|
except Exception:
|
|
self.__cfg.state_busy = False
|
|
raise
|
|
|
|
def goto_abr_meas_pos(self):
|
|
self.__cfg.set_busy(BeamlineStateEnum.SampleAlignment)
|
|
try:
|
|
self.__devs.aerotech_pos = self.__cfg.abr_meas_pos
|
|
self.__cfg.state_busy = False
|
|
except Exception:
|
|
self.__cfg.state_busy = False
|
|
raise
|
|
|
|
def create_sample(self, target: SampleShortInfo):
|
|
|
|
self.__cfg.try_set_busy(timeout=360)
|
|
|
|
try:
|
|
curr_sample = self.__cfg.current_sample
|
|
|
|
if curr_sample is not None and curr_sample.location is not None:
|
|
raise Exception("Sample from TELL is loaded")
|
|
|
|
self.__aare.create_manual_sample(target)
|
|
self.__cfg.current_sample = target
|
|
self.__cfg.state_busy = False
|
|
except Exception:
|
|
self.__cfg.state_busy = False
|
|
raise
|
|
|
|
def park_and_dry(self):
|
|
self.__cfg.try_set_busy(timeout=360)
|
|
try:
|
|
self.__devs.tell.wait_not_busy()
|
|
self.__devs.tell.set_in_mount_position(True)
|
|
self.__devs.tell.unmount(wait=True, timeout=60.0)
|
|
self.__devs.tell.dry(wait_cold=-1, wait=True, timeout=360.0)
|
|
self.__cfg.current_sample = None
|
|
self.__cfg.state_busy = False
|
|
except Exception as e:
|
|
self.__cfg.state_busy = False
|
|
logger.error(f"Failed to park and dry: {e}")
|
|
raise
|
|
|
|
def __magnet_position_sensor_check(self, timeout: float = 1.0, repeat: bool = True):
|
|
#TODO check this works, add beamstop z controls and test.
|
|
|
|
# logger.info("checking beamstop")
|
|
# if self.__devs.beamstop_z.value < 24.0:
|
|
# raise Exception("Beamstop Z below 24.0 mm - potentially unsafe with mounting")
|
|
if self.__devs.magnet_position_sensor.value != 0:
|
|
logger.warning("Goniometer is not in position based on magnet position sensor readout")
|
|
for i in range(round(timeout * 10.0)):
|
|
if self.__devs.magnet_position_sensor.value == 0:
|
|
return
|
|
time.sleep(0.1)
|
|
logger.error("Goniometer didn't reach position based on magnet position sensor readout")
|
|
raise Exception("Goniometer is not in position based on magnet position sensor readout")
|
|
|
|
|
|
def __mount_failure_handler(self, mount_error):
|
|
pass
|
|
|
|
def __mount(self, target: SampleShortInfo | None):
|
|
self.__devs.smargon_move_home()
|
|
self.__devs.aerotech_pos = ABR_POS_MOUNT
|
|
#collimator should be down!!!
|
|
self.__magnet_position_sensor_check(timeout=360.0)
|
|
self.__devs.tell.wait_not_busy()
|
|
self.__devs.tell.set_in_mount_position(True)
|
|
if target is None:
|
|
self.__devs.tell.unmount(wait=True, timeout=60.0)
|
|
self.__aare.sample_unmounted(self.__cfg.current_sample)
|
|
self.__cfg.current_sample = None
|
|
else:
|
|
try:
|
|
value = self.__devs.tell.mount(address=target.tell_address(), force=True, auto_unmount=True, read_dm=False,
|
|
wait=True, timeout=360.0)
|
|
if self.__cfg.current_sample is not None and self.__cfg.current_sample.db_id is not None:
|
|
self.__aare.sample_unmounted(self.__cfg.current_sample)
|
|
logger.info(f"Mount result: {value}")
|
|
self.__cfg.current_sample = target
|
|
except Exception as e:
|
|
logger.error(f"Mount failed: {e}")
|
|
if self.__cfg.current_sample is not None and self.__cfg.current_sample.db_id is not None:
|
|
self.__aare.sample_failed(self.__cfg.current_sample, failed_comment=f"Mount failed: {e}")
|
|
raise MountingFailed(f"Mount failed: {e}")
|
|
#self.__mount_failure_handler(value)
|
|
|
|
def recovery_unmount_sample(self) -> None:
|
|
self.__cfg.try_set_busy(timeout=360)
|
|
try:
|
|
self.__set_state(BeamlineStateEnum.RobotSampleExchange)
|
|
self.__devs.tell.wait_not_busy()
|
|
self.__devs.tell.set_in_mount_position(True)
|
|
self.__devs.tell.unmount(wait=True, timeout=360.0)
|
|
self.__cfg.current_sample = None
|
|
self.__set_state(BeamlineStateEnum.SampleAlignment)
|
|
self.__cfg.state_busy = False
|
|
except Exception:
|
|
self.__cfg.state_busy = False
|
|
raise
|
|
|
|
@sample.setter
|
|
def sample(self, target: SampleShortInfo | None):
|
|
self.__cfg.try_set_busy(timeout=360)
|
|
curr_sample_is_manual=False
|
|
try:
|
|
curr_sample = self.__cfg.current_sample
|
|
workflows.common_2rse(devs=self.__devs, cfg=self.__cfg)
|
|
#if curr_sample is not None and curr_sample.location is None:
|
|
# self.__cfg.current_sample = None
|
|
# curr_sample_is_manual = True
|
|
#if target is not None or not curr_sample_is_manual:
|
|
logger.debug(target)
|
|
if not self._execute_mount_and_prepare(target):
|
|
raise MountingFailed("Failed to mount sample")
|
|
logger.info(f"Sample mounted: {target}")
|
|
except Exception as e:
|
|
self.__cfg.state_busy = False
|
|
logger.debug(f"Failed to mount sample: {e}")
|
|
# self.__aare.sample_failed(target, f"Mount failed due to {e}")
|
|
raise
|
|
workflows.rse2sa(devs=self.__devs, cfg=self.__cfg)
|
|
self.__cfg.state_busy = False
|
|
# if target is not None:
|
|
# if target.db_id is not None:
|
|
# self.__aare.sample_mounted(target)
|
|
# self.save_screenshot_db(target.db_id, f"{target.db_id}_mounted")
|
|
|
|
|
|
@property
|
|
def camera_image(self) -> np.ndarray | None:
|
|
image = self.__devs.samcam_get_image(gray=False)
|
|
return image
|
|
|
|
@property
|
|
def camera_image_gray(self) -> np.ndarray | None:
|
|
image = self.__devs.samcam_get_image(gray=True)
|
|
return image
|
|
|
|
def list_loaded_pucks(self) -> List[PuckLoadedInfo]:
|
|
return []
|
|
|
|
def __auto_focus(self, settings: AutofocusSettings, settle_time_s: float = 1.0) -> float:
|
|
"""
|
|
Scan smargon Z and find the position with maximum focus measure.
|
|
|
|
Args:
|
|
settings: AutofocusSettings with center, radius, range, and steps
|
|
settle_time_s: Time to wait after each move before capturing image
|
|
|
|
Returns:
|
|
Best Z position (mm) found during the scan
|
|
"""
|
|
geom = self.sample_geometry
|
|
current_smargon = self.__devs.smargon_pos
|
|
|
|
# Get center for the mask (use beam center if not specified)
|
|
center_x = geom.beam_location_pxl.x
|
|
center_y = geom.beam_location_pxl.y
|
|
radius_pxl = 30
|
|
|
|
# Convert z_range from um to mm
|
|
z_range_mm = settings.z_range_um / 1000.0
|
|
n_steps = settings.z_steps
|
|
|
|
# Starting Z position (current sh_mm)
|
|
z_start = 0.0
|
|
z_min = z_start - z_range_mm / 2.0
|
|
z_max = z_start + z_range_mm / 2.0
|
|
z_step = z_range_mm / (n_steps - 1) if n_steps > 1 else 0.0
|
|
|
|
# Pre-compute mask (will be created on first image)
|
|
focus_mask: np.ndarray | None = None
|
|
|
|
# Collect focus measures at each Z position
|
|
z_positions: list[float] = []
|
|
focus_values: list[float] = []
|
|
|
|
print(f"Starting autofocus: z_range={z_range_mm * 1000:.1f}um, steps={n_steps}, "
|
|
f"center=({center_x:.1f}, {center_y:.1f}), radius={radius_pxl:.1f}px")
|
|
|
|
|
|
for i in range(n_steps):
|
|
z_pos = z_min + i * z_step
|
|
|
|
# Move to position
|
|
target = SmargonCoordinate(
|
|
chi_deg=current_smargon.chi_deg,
|
|
phi_deg=current_smargon.phi_deg,
|
|
sh_mm=geom.beamline_to_smargon(Coordinate(z=z_pos))
|
|
)
|
|
self.__devs.smargon_pos = target
|
|
self.__devs.smargon_wait(timeout=30)
|
|
|
|
# Wait for mechanical settling and image stabilization
|
|
time.sleep(settle_time_s)
|
|
|
|
# Capture image
|
|
gray = self.camera_image_gray
|
|
|
|
if gray is None:
|
|
logger.warning(f"Failed to get image at z={z_pos:.4f}")
|
|
continue
|
|
|
|
# Create mask on first valid image
|
|
if focus_mask is None or focus_mask.shape != gray.shape:
|
|
h, w = gray.shape
|
|
y, x = np.ogrid[:h, :w]
|
|
focus_mask = (x - center_x) ** 2 + (y - center_y) ** 2 <= radius_pxl ** 2
|
|
|
|
print(focus_mask.shape)
|
|
print(gray.shape)
|
|
|
|
# Calculate focus measure
|
|
fm = focus_measure_edges(gray, focus_mask)
|
|
|
|
z_positions.append(z_pos)
|
|
focus_values.append(fm)
|
|
logger.debug(f"Autofocus step {i + 1}/{n_steps}: z={z_pos:.4f}mm, focus={fm:.2f}")
|
|
print(f"Autofocus step {i + 1}/{n_steps}: z={z_pos:.4f}mm, focus={fm:.2f}")
|
|
if len(z_positions) < 3:
|
|
logger.error("Autofocus failed: not enough valid measurements")
|
|
# Return to original position
|
|
self.__devs.smargon_pos = current_smargon
|
|
self.__devs.smargon_wait(timeout=30)
|
|
return z_start
|
|
|
|
# Find best position - use parabolic fit around the peak for sub-step precision
|
|
z_arr = np.array(z_positions)
|
|
fm_arr = np.array(focus_values)
|
|
|
|
# Find index of maximum
|
|
peak_idx = int(np.argmax(fm_arr))
|
|
|
|
# Try parabolic fit if peak is not at the edge
|
|
if 0 < peak_idx < len(fm_arr) - 1:
|
|
# Fit parabola to 3 points around peak: f(z) = a*z^2 + b*z + c
|
|
z_fit = z_arr[peak_idx - 1: peak_idx + 2]
|
|
fm_fit = fm_arr[peak_idx - 1: peak_idx + 2]
|
|
try:
|
|
coeffs = np.polyfit(z_fit, fm_fit, 2)
|
|
a, b, c = coeffs
|
|
if a < 0: # Parabola opens downward (valid peak)
|
|
best_z = -b / (2 * a)
|
|
# Sanity check: best_z should be within the fitted range
|
|
if z_fit[0] <= best_z <= z_fit[2]:
|
|
logger.info(f"Autofocus: parabolic fit found peak at z={best_z:.4f}mm")
|
|
else:
|
|
best_z = z_arr[peak_idx]
|
|
logger.info(f"Autofocus: parabolic fit out of range, using sample peak z={best_z:.4f}mm")
|
|
else:
|
|
best_z = z_arr[peak_idx]
|
|
logger.info(f"Autofocus: invalid parabola, using sample peak z={best_z:.4f}mm")
|
|
except Exception as e:
|
|
logger.warning(f"Parabolic fit failed: {e}, using sample peak")
|
|
best_z = z_arr[peak_idx]
|
|
else:
|
|
best_z = z_arr[peak_idx]
|
|
logger.warning(f"Autofocus: peak at edge of scan range, z={best_z:.4f}mm")
|
|
|
|
# Move to best position
|
|
best_target = SmargonCoordinate(
|
|
chi_deg=current_smargon.chi_deg,
|
|
phi_deg=current_smargon.phi_deg,
|
|
sh_mm=geom.beamline_to_smargon(Coordinate(z=best_z)),
|
|
)
|
|
self.__devs.smargon_pos = best_target
|
|
self.__devs.smargon_wait(timeout=30)
|
|
|
|
logger.warning(f"Autofocus complete: best_z={best_z:.4f}mm, "
|
|
f"focus_range=[{min(fm_arr):.2f}, {max(fm_arr):.2f}]")
|
|
|
|
return best_z
|
|
|
|
def auto_focus(self, settings: AutofocusSettings) -> float:
|
|
"""
|
|
Public autofocus method. Only allowed in SampleAlignment state.
|
|
|
|
Args:
|
|
settings: AutofocusSettings with center, radius, range, and steps
|
|
|
|
Returns:
|
|
Best Z position (mm) found during the scan
|
|
"""
|
|
self.__cfg.set_busy(BeamlineStateEnum.SampleAlignment)
|
|
try:
|
|
best_z = self.__auto_focus(settings)
|
|
self.__cfg.state_busy = False
|
|
return best_z
|
|
except Exception as e:
|
|
logger.error(f"Autofocus failed: {e}")
|
|
self.__cfg.state_busy = False
|
|
raise
|
|
|
|
|
|
def __auto_center(self, request: RasterGridRequest) -> CompletedRasterGrid | None:
|
|
sample = self.sample
|
|
|
|
if sample is None:
|
|
raise Exception("Sample must be mounted to auto center")
|
|
|
|
old_prefix = request.file_prefix
|
|
geom = self.sample_geometry
|
|
r = self.__ml_bounding_box(sample.db_id, f"ml_{geom.omega_deg:.2f}deg")
|
|
|
|
if r is None:
|
|
self.__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")
|
|
|
|
if r is not None:
|
|
geom = self.sample_geometry
|
|
grid = copy.deepcopy(request)
|
|
grid.smargon_top_left = r.smargon_top_left
|
|
grid.grid_size_mm = r.grid_size_mm
|
|
grid.n_x = r.n_x
|
|
grid.n_y = r.n_y
|
|
grid.file_prefix = f"{old_prefix}_{grid.omega_deg}deg"
|
|
grid.omega_deg = geom.omega_deg
|
|
res1 = self.__raster(grid)
|
|
grid.omega_deg += 90
|
|
self.__devs.aerotech_omega = grid.omega_deg
|
|
|
|
grid.n_x = 1
|
|
#TODO generate y scan rather than had code for 1x50
|
|
grid.n_y = 50
|
|
grid.file_prefix = f"{old_prefix}_{grid.omega_deg}deg"
|
|
grid.grid_size_mm = Coordinate(x=geom.beam_size_mm.x, y=geom.beam_size_mm.y * 0.25)
|
|
offset = Coordinate(x=-grid.grid_size_mm.x/2.0, y=-(grid.n_y + 0.5) * grid.grid_size_mm.y / 2.0)
|
|
geom = self.sample_geometry
|
|
logger.debug(f"set offset y scan {offset}")
|
|
# self.__devs.smargon.target = geom.translate_smargon(offset)
|
|
grid.smargon_top_left = geom.translate_smargon(offset)
|
|
res2 = self.__raster(grid)
|
|
return CompletedRasterGrid(r=[res1, res2])
|
|
else:
|
|
return None
|
|
|
|
def __setup_datacollection(self, request: RasterGridRequest | RotationScanRequest, screening: bool = False):
|
|
|
|
if request.dtz is not None:
|
|
logger.info(f'requesting dtz to move to {request.dtz}')
|
|
self.__cfg.dtz = request.dtz
|
|
|
|
if self.sample is not None and self.sample.db_id is not None:
|
|
sample_id = self.sample.db_id
|
|
if screening:
|
|
screenshot_name = f"{sample_id}_before_screening"
|
|
elif type(request) is RasterGridRequest:
|
|
screenshot_name = f"{sample_id}_before_raster"
|
|
else:
|
|
screenshot_name = f"{sample_id}_before_data_collection"
|
|
self.save_screenshot_db(sample_id, screenshot_name)
|
|
|
|
self.__set_state(BeamlineStateEnum.DataCollection)
|
|
|
|
if request.transmission is not None:
|
|
logger.info(f'requesting transmission to move to {request.transmission}')
|
|
self.__devs.transmission = request.transmission
|
|
|
|
if hasattr(request, 'start') and request.start is not None:
|
|
self.__devs.smargon_pos = request.start
|
|
elif hasattr(request, 'smargon_top_left') and request.smargon_top_left is not None:
|
|
self.__devs.set_smargon_pos(SmargonCoordinate(sh_mm=request.smargon_top_left.sh_mm,
|
|
phi_deg=request.smargon_top_left.phi_deg,
|
|
chi_deg=request.smargon_top_left.chi_deg))
|
|
|
|
#if request.transmission is not None:
|
|
# self.__devs.transmission.wait()
|
|
self.__devs.smargon_wait(timeout=180)
|
|
|
|
return
|
|
|
|
def _build_fake_scan_result(self, *, file_prefix: str | None, image_count: int) -> ScanResult:
|
|
images = [
|
|
ScanResultImagesInner(
|
|
number=i,
|
|
efficiency=1.0,
|
|
bkg=0.0,
|
|
spots=0,
|
|
spots_low_res=0,
|
|
spots_indexed=0,
|
|
index=0,
|
|
b=0.0,
|
|
)
|
|
for i in range(max(1, image_count))
|
|
]
|
|
return ScanResult(file_prefix=file_prefix, images=images)
|
|
|
|
def _build_fake_rotation_result(self, request: RotationScanRequest) -> CompletedRotationScan:
|
|
result = self._build_fake_scan_result(
|
|
file_prefix=request.file_prefix,
|
|
image_count=request.steps,
|
|
)
|
|
return CompletedRotationScan(
|
|
request=copy.deepcopy(request),
|
|
result=result,
|
|
)
|
|
|
|
def _build_fake_raster_result(self, request: RasterGridRequest) -> CompletedRasterGridElem:
|
|
result = self._build_fake_scan_result(
|
|
file_prefix=request.file_prefix,
|
|
image_count=request.n_x * request.n_y,
|
|
)
|
|
return CompletedRasterGridElem(
|
|
request=copy.deepcopy(request),
|
|
result=result,
|
|
centre_of_mass=None,
|
|
)
|
|
|
|
def __raster(self, request: RasterGridRequest) -> CompletedRasterGridElem:
|
|
self.__devs.aerotech_omega = request.omega_deg
|
|
self.__setup_datacollection(request=request)
|
|
|
|
status = self.status
|
|
logger.info(f"raster status {status}")
|
|
logger.info(f'raster grid request: {request}')
|
|
|
|
total_time = request.exp_time_s*request.n_x*request.n_y+request.n_y*0.3
|
|
|
|
try:
|
|
if self.sample is not None and self.sample.db_id is not None:
|
|
self.__aare.create_gridscan_run(self.sample, request, status)
|
|
|
|
if not self.__cfg.simulated_detector:
|
|
logger.info("initialise detector")
|
|
self.__jfjoch.measure_raster(request, status)
|
|
logger.info("detector initialised")
|
|
else:
|
|
logger.info("Simulated detector mode enabled; using fake raster result.")
|
|
|
|
self.__devs.aerotech.grid_scan(
|
|
grid_elem_size_y_um=request.grid_size_mm.y * 1000,
|
|
grid_elem_size_x_um=request.grid_size_mm.x * 1000,
|
|
grid_elem_count_x=request.n_x,
|
|
grid_elem_count_y=request.n_y,
|
|
time_sec=request.exp_time_s,
|
|
run_async=True,
|
|
)
|
|
|
|
self.__devs.aerotech.wait_till_done(timeout=int(round(total_time * 2, 0)))
|
|
#go back to aerotech x,y,z home not U home (0 degrees).
|
|
if isinstance(self.__cfg.abr_meas_pos, Coordinate):
|
|
coord = self.__cfg.abr_meas_pos
|
|
else:
|
|
coord = self.__cfg.abr_meas_pos.at_mm
|
|
self.__devs.aerotech_pos = AerotechCoordinate(at_mm=coord, omega_deg=self.__devs.aerotech_omega)
|
|
self.__devs.aerotech.wait_till_done(timeout=int(360))
|
|
|
|
if request.n_x == 1:
|
|
x = request.grid_size_mm.x / 2.0
|
|
y = ((request.n_y - 1) * request.grid_size_mm.y) / 2.0
|
|
grid_centre_offset = self.sample_geometry.smargon_nudge(Coordinate(x=x, y=y))
|
|
else:
|
|
x = ((request.n_x - 1) * request.grid_size_mm.x) / 2.0
|
|
y = ((request.n_y - 1) * request.grid_size_mm.y) / 2.0
|
|
grid_centre_offset = self.sample_geometry.smargon_nudge(Coordinate(x=x, y=y))
|
|
|
|
logger.info(f"moving Smargon to grid centre offset {grid_centre_offset}")
|
|
|
|
grid_centre_smargon = SmargonCoordinate(
|
|
sh_mm=request.smargon_top_left.sh_mm + grid_centre_offset,
|
|
phi_deg=request.smargon_top_left.phi_deg,
|
|
chi_deg=request.smargon_top_left.chi_deg
|
|
)
|
|
self.__devs.smargon_pos = grid_centre_smargon
|
|
self.__devs.smargon_wait(timeout=180)
|
|
|
|
if self.__cfg.simulated_detector:
|
|
scan_result = self._build_fake_scan_result(
|
|
file_prefix=request.file_prefix,
|
|
image_count=request.n_x * request.n_y,
|
|
)
|
|
else:
|
|
scan_result = self.__jfjoch.wait_till_done(60)
|
|
if scan_result is None:
|
|
logger.warning("JFJoch returned no ScanResult; using fake result for raster scan.")
|
|
scan_result = self._build_fake_scan_result(
|
|
file_prefix=request.file_prefix,
|
|
image_count=request.n_x * request.n_y,
|
|
)
|
|
|
|
sample_id = self.sample.db_id if self.sample and self.sample.db_id is not None else None
|
|
if sample_id:
|
|
self.__set_state(BeamlineStateEnum.SampleAlignment)
|
|
self.save_screenshot_db(sample_id, f"{sample_id}_post_raster_{request.omega_deg}deg")
|
|
self.__aare.ingest_gridscan(
|
|
sample=self.sample,
|
|
raster_result=scan_result,
|
|
raster_request=request,
|
|
geom=self.sample_geometry,
|
|
com=None,
|
|
beam_mark_pxl=self.__cfg.get_beam_mark(self.zoom),
|
|
)
|
|
|
|
return CompletedRasterGridElem(
|
|
request=copy.deepcopy(request),
|
|
result=scan_result,
|
|
centre_of_mass=None,
|
|
)
|
|
|
|
except Exception:
|
|
logger.exception("Failed during raster")
|
|
raise
|
|
|
|
def measure_raster(self, r: RasterGridRequest, auto: bool) -> CompletedRasterGrid:
|
|
"""
|
|
Execute a raster scan.
|
|
|
|
Args:
|
|
r: RasterGridRequest parameters for the scan.
|
|
auto: Boolean flag indicating if this is part of an automated sequence.
|
|
|
|
Returns:
|
|
CompletedRasterGrid result.
|
|
"""
|
|
self.__cfg.try_set_busy(timeout=ceil(360))
|
|
try:
|
|
result = self._execute_raster_sequence(r, auto_center=auto)
|
|
|
|
if result is None:
|
|
raise Exception("Raster scan failed")
|
|
|
|
self.__cfg.state_busy = False
|
|
return result
|
|
except Exception as e:
|
|
self.__set_state(BeamlineStateEnum.SampleAlignment)
|
|
self.__cfg.state_busy = False
|
|
raise
|
|
|
|
def __rotation(self, request: RotationScanRequest) -> CompletedRotationScan:
|
|
omega_start = self.omega
|
|
status = self.status
|
|
|
|
#self.__aare.create_rotation_run(self.sample, request, status)
|
|
total_time = request.exp_time_s * request.steps
|
|
if self.sample is not None and self.sample.db_id is not None:
|
|
self.__aare.create_rotation_run(self.sample, request, status)
|
|
try:
|
|
|
|
if self.__cfg.simulated_detector:
|
|
logger.info("Simulated detector mode enabled; skipping JFJoch start.")
|
|
else:
|
|
self.__jfjoch.measure_rotation(request, status, self.__cfg.xrf)
|
|
|
|
if request.screening:
|
|
self.__devs.aerotech.screening_scan(
|
|
rotation_deg=request.steps*request.incr_omega_deg,
|
|
wedge_deg=request.wedge_omega_deg,
|
|
time_sec=total_time,
|
|
steps=request.steps,
|
|
run_async=True,
|
|
)
|
|
else:
|
|
self.__devs.aerotech.rotation_scan(
|
|
rotation_deg=request.steps*request.incr_omega_deg,
|
|
time_sec=total_time,
|
|
start_pos_deg=request.start_omega_deg,
|
|
run_async=True,
|
|
)
|
|
|
|
#Is this for helical scans...? do we do smargon scans?
|
|
if request.start is not None and request.end is not None:
|
|
smargon_time_step = request.exp_time_s / float(request.steps)
|
|
pos_step = (request.end.sh_mm - request.start.sh_mm) * (1.0 / float(request.steps))
|
|
|
|
for i in range(request.steps):
|
|
self.__devs.smargon.target = SmargonCoordinate(
|
|
sh_mm=request.start.sh_mm + pos_step * i
|
|
)
|
|
time.sleep(smargon_time_step)
|
|
|
|
self.__devs.aerotech.wait_till_done(timeout=int(round(total_time + 60,0)))
|
|
self.__devs.aerotech_omega = omega_start
|
|
|
|
# self.__aare.sample_collected(self.sample)
|
|
|
|
if self.__cfg.simulated_detector:
|
|
logger.warning("Detector in simulation mode, returning fake zero rotation result.")
|
|
result = self._build_fake_rotation_result(request)
|
|
else:
|
|
# Let JFJochCommunicationError propagate
|
|
result = self.__jfjoch.wait_till_done(60)
|
|
|
|
except JFJochCommunicationError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Exception during rotation scan: {e}")
|
|
raise
|
|
|
|
return result
|
|
|
|
def measure_rotation(self, request: RotationScanRequest) -> CompletedRotationScan:
|
|
"""
|
|
Execute a rotation scan.
|
|
|
|
Args:
|
|
request: RotationScanRequest parameters.
|
|
|
|
Returns:
|
|
CompletedRotationScan result.
|
|
"""
|
|
total_time = request.exp_time_s * request.steps
|
|
logger.info(f"received rotation scan request: {request}, total time: {total_time}s, steps: {request.steps}")
|
|
self.__cfg.try_set_busy(timeout=ceil(total_time + 360))
|
|
|
|
try:
|
|
result = self._execute_rotation_sequence(request)
|
|
|
|
if result is None:
|
|
raise Exception("Rotation scan failed")
|
|
self.__cfg.state_busy = False
|
|
return result
|
|
except Exception as e:
|
|
self.__set_state(BeamlineStateEnum.SampleAlignment)
|
|
self.__cfg.state_busy = False
|
|
raise
|
|
|
|
@property
|
|
def dtz(self) -> float:
|
|
tmp = self.__cfg.dtz
|
|
if tmp is None:
|
|
return 150.0
|
|
else:
|
|
return tmp
|
|
|
|
@dtz.setter
|
|
def dtz(self, val: float):
|
|
self.__cfg.try_set_busy(timeout=360)
|
|
state = self.__cfg.state
|
|
|
|
if val < self.__devs.dtz_low or val > self.__devs.dtz_high:
|
|
self.__cfg.state_busy = False
|
|
raise RuntimeError(f"dtz={val} outside limits {self.__devs.dtz_low} to {self.__devs.dtz_high}")
|
|
|
|
if state == BeamlineStateEnum.DataCollection:
|
|
self.__cfg.state_busy = False
|
|
raise RuntimeError("Cannot set dtz during data collection")
|
|
elif state == BeamlineStateEnum.SampleAlignment:
|
|
self.__devs.set_dtz(val, wait=False)
|
|
self.__cfg.dtz = val
|
|
self.__cfg.state_busy = False
|
|
|
|
@property
|
|
def smargon(self) -> SmargonCoordinate:
|
|
return self.__devs.smargon_pos
|
|
|
|
@smargon.setter
|
|
def smargon(self, sc: SmargonCoordinate):
|
|
self.__cfg.set_busy(BeamlineStateEnum.SampleAlignment)
|
|
try:
|
|
self.__saved_box = None
|
|
self.__devs.smargon_pos = sc
|
|
self.__devs.smargon_wait()
|
|
self.__cfg.state_busy = False
|
|
except Exception as e:
|
|
self.__cfg.state_busy = False
|
|
raise
|
|
pass
|
|
|
|
def mark_beam(self, x_pxl: float, y_pxl: float):
|
|
self.__cfg.set_busy(BeamlineStateEnum.BeamLocation)
|
|
try:
|
|
self.__cfg.mark_beam(x_pxl, y_pxl, self.__devs.zoom)
|
|
self.__cfg.state_busy = False
|
|
except Exception as e:
|
|
self.__cfg.state_busy = False
|
|
raise
|
|
|
|
def clear_mark_beam(self):
|
|
self.__cfg.set_busy(BeamlineStateEnum.BeamLocation)
|
|
self.__cfg.clear_mark_beam()
|
|
self.__cfg.state_busy = False
|
|
|
|
@property
|
|
def sample_geometry(self) -> SampleGeometryModel:
|
|
zoom = self.__devs.zoom
|
|
aerotech_pos_ref = self.__cfg.abr_meas_pos.at_mm
|
|
aerotech_pos = self.__devs.aerotech_pos.at_mm
|
|
sample_geom = SampleGeometryModel(
|
|
beam_location_pxl=self.__cfg.beam_mark_coeff.apply(zoom),
|
|
pixel_in_mm=self.__cfg.pixel_to_mm(zoom),
|
|
omega_deg=self.__devs.aerotech_omega,
|
|
smargon=self.__devs.smargon_pos,
|
|
beam_size_mm=self.__cfg.beam_size_mm,
|
|
aerotech=aerotech_pos - aerotech_pos_ref,
|
|
aerotech_meas=aerotech_pos
|
|
)
|
|
return sample_geom
|
|
|
|
@property
|
|
def beam_center(self) -> Tuple[float, float]:
|
|
return self.__cfg.beam_center
|
|
|
|
@beam_center.setter
|
|
def beam_center(self, val: Tuple[float, float]):
|
|
self.__cfg.beam_center = val
|
|
|
|
@property
|
|
def beam_size_mm(self) -> Coordinate:
|
|
return self.__cfg.beam_size_mm
|
|
|
|
@beam_size_mm.setter
|
|
def beam_size_mm(self, val: Coordinate):
|
|
self.__cfg.beam_size_mm = val
|
|
|
|
def get_beam_mark(self):
|
|
return self.__cfg.get_beam_mark(self.__devs.zoom)
|
|
|
|
def __ml_bounding_box(self, sample_id: int | None = None, filename: str | None = None) -> RasterGridRequest | None:
|
|
time.sleep(0.2) # Just to be sure image is stable
|
|
#curr_image = self.camera_image
|
|
#box = self.__mlbox.predict(curr_image)
|
|
curr_image = cv2.cvtColor(self.camera_image, cv2.COLOR_RGB2BGR)
|
|
m = self.__mlbox.predict(curr_image, preferred_class=(3,0))
|
|
if m is None:
|
|
if filename is not None:
|
|
#cv2.imwrite(f"{filename}_no_detection.jpg", curr_image)
|
|
self.__aare.upload_image(sample_id, f"{filename}_no_detection", curr_image)
|
|
return None
|
|
x1, y1, x2, y2 = m.box.top_x, m.box.top_y, m.box.bottom_x, m.box.bottom_y
|
|
|
|
if filename is not None:
|
|
cv2.rectangle(curr_image, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2)
|
|
#cv2.imwrite(f"{filename}.jpg", curr_image)
|
|
self.__aare.upload_image(sample_id, filename, curr_image)
|
|
|
|
geom = self.sample_geometry
|
|
|
|
start_coord = geom.picture_to_smargon(Coordinate(x=x1, y=y1))
|
|
grid_size = Coordinate(x=geom.beam_size_mm.x * 0.8, y=geom.beam_size_mm.y * 0.8)
|
|
n_x = abs(ceil((x2 - x1) * geom.pixel_in_mm / grid_size.x))
|
|
n_y = abs(ceil((y2 - y1) * geom.pixel_in_mm / grid_size.y))
|
|
|
|
return RasterGridRequest(
|
|
exp_time_s=0.02,
|
|
transmission=1.0,
|
|
smargon_top_left = SmargonCoordinate(chi_deg = geom.smargon.chi_deg,
|
|
phi_deg= geom.smargon.phi_deg,
|
|
sh_mm=start_coord),
|
|
n_x=n_x,
|
|
n_y=n_y,
|
|
grid_size_mm= grid_size,
|
|
omega_deg=geom.omega_deg
|
|
)
|
|
|
|
def __ml_loop_centre_box(self, sample_id: int | None = None, filename: str | None = None) -> tuple[SmargonCoordinate | None, int| None, list[int] | None]:
|
|
#time.sleep(0.2) # Just to be sure image is stable
|
|
bgr_image = cv2.cvtColor(self.camera_image, cv2.COLOR_RGB2BGR)
|
|
boxes = self.__mlbox.predict_all_best(bgr_image, overlap_with_pin = 0.5, confidence_min=0.3)
|
|
|
|
if boxes is None:
|
|
if filename is not None:
|
|
self.__aare.upload_image(sample_id, f"{filename}_no_detection", bgr_image)
|
|
return None, None, None
|
|
|
|
classes: list[int] = []
|
|
pin = None
|
|
centre_x, centre_y = None, None
|
|
for box in boxes.boxes.values():
|
|
if box and box.cls is not None:
|
|
classes.append(int(box.cls.value))
|
|
if int(box.cls.value) == 1:
|
|
pin=box
|
|
|
|
best_box = self.__mlbox.get_preferred_class_box(boxes, (2,3,0,1))
|
|
if best_box is None or best_box.box is None or best_box.cls is None:
|
|
return None, None, classes if classes else None
|
|
|
|
|
|
cls = int(best_box.cls.value)
|
|
x1 = best_box.box.top_x
|
|
y1 = best_box.box.top_y
|
|
x2 = best_box.box.bottom_x
|
|
y2 = best_box.box.bottom_y
|
|
|
|
if filename is not None:
|
|
self.__aare.upload_image(sample_id, filename, bgr_image)
|
|
|
|
geom = self.sample_geometry
|
|
|
|
if cls == 0: # loop_all
|
|
if y1 + y2 <= x1 + x2:
|
|
centre_y = y1 + (y2 - y1) / 2
|
|
centre_x = x1
|
|
elif pin:
|
|
position_dict = self.__mlbox.check_box_relation(pin, best_box)
|
|
if position_dict["overlap_y"] and position_dict["overlap_x"]:
|
|
centre_y = y1 + (y2 - y1) / 2
|
|
centre_x = x1
|
|
cls = 1
|
|
logger.debug("significant overlap between pin and loop_all not picked up by ML")
|
|
else:
|
|
centre_y = y2 if position_dict["top"] else y1
|
|
centre_x = x1 + (x2 - x1) / 2
|
|
else:
|
|
centre_y = y1
|
|
centre_x = x1 + (x2 - x1) / 2
|
|
|
|
elif cls == 1: # pin
|
|
centre_y = y1 + (y2 - y1)/2
|
|
centre_x = x1
|
|
|
|
elif cls == 2 or cls == 3: #crystal or loop_face
|
|
centre_y = y1 + (y2 - y1)/2
|
|
centre_x = x1 + (x2 - x1)/2
|
|
|
|
else:
|
|
logger.debug(f"unknown box class {cls}")
|
|
return None, cls, classes if classes else None
|
|
|
|
coord = geom.picture_to_smargon(Coordinate(x=centre_x, y=centre_y))
|
|
return SmargonCoordinate(sh_mm=coord), cls, classes
|
|
|
|
def ml_bounding_box(self, sample_id: int | None = None, filename: str | None = None) -> RasterGridRequest | None:
|
|
"""
|
|
Request an ML-based bounding box for the sample.
|
|
|
|
Args:
|
|
sample_id: Optional sample ID.
|
|
filename: Optional filename for saving/logging.
|
|
|
|
Returns:
|
|
RasterGridRequest object representing the found bounding box, or None if failed.
|
|
"""
|
|
try:
|
|
self.__cfg.try_set_busy(timeout=360)
|
|
r = self.__ml_bounding_box(sample_id, filename)
|
|
self.__cfg.state_busy = False
|
|
return r
|
|
except Exception:
|
|
self.__cfg.state_busy = False
|
|
raise
|
|
|
|
def face_detection(self, steps: int = 14, step_size: int = 15, face_min_ratio: float =0.3) -> dict:
|
|
"""
|
|
Perform a face detection sequence by rotating the sample and using ML to find the flat face.
|
|
|
|
Args:
|
|
steps: Number of rotation steps. Default is 14.
|
|
step_size: Size of each rotation step in degrees. Default is 15.
|
|
face_min_ratio: Minimum ratio of loopface count to loop_all count to use loop_face over loop_all.
|
|
i.e. if 10 loop_face vs 4 loop_all pick loop_face. if 2 loop_face and 12 loop_all use loop_all.
|
|
Default is 0.3.
|
|
|
|
Returns:
|
|
Dictionary containing face detection results, including found samples and fits.
|
|
"""
|
|
self.__cfg.try_set_busy(timeout=360)
|
|
try:
|
|
logger.info("running face detection sequence")
|
|
result = self.__face_detection_sequence(steps=steps, step_size=step_size,face_min_ratio=face_min_ratio)
|
|
except Exception as e:
|
|
logger.error(f"error in face detection sequence {e}")
|
|
result = {
|
|
"running": False,
|
|
"samples": [],
|
|
"height_fit": {},
|
|
"area_fit": {},
|
|
}
|
|
self._emit_face_detection_progress(result)
|
|
self.__cfg.state_busy = False
|
|
return result
|
|
|
|
def face_detection_centre_correction(self, m:MLBoxModel, tolerance: float = 0.2):
|
|
geom = self.sample_geometry
|
|
beam_y = geom.beam_location_pxl.y
|
|
beam_x = geom.beam_location_pxl.x
|
|
|
|
x1 = m.box.top_x
|
|
y1 = m.box.top_y
|
|
y2 = m.box.bottom_y
|
|
|
|
centre_y = y1 + (y2 - y1) / 2
|
|
centre_x = x1
|
|
|
|
if beam_y !=0 and abs(centre_y-beam_y)/abs(beam_y) > tolerance:
|
|
coord = geom.picture_to_smargon(Coordinate(x=beam_x, y=centre_y))
|
|
self.__devs.smargon_pos = SmargonCoordinate(sh_mm=coord)
|
|
self.__devs.smargon_wait(60)
|
|
|
|
return
|
|
|
|
def __face_detection_sequence(self, steps: int = 14, step_size: int = 15, face_min_ratio: float = 0.3) -> dict:
|
|
self.__set_state(BeamlineStateEnum.SampleAlignment)
|
|
self.__devs.lamp_light = 2.5
|
|
self.__cfg.zoom_mode = ZoomModeEnum.LoopCenter
|
|
|
|
zoom_value = self.__devs.zoom
|
|
logger.info('face detection sequence')
|
|
|
|
self.__devs.set_zoom(zoom_value, wait=True)
|
|
|
|
boxes_face: dict[int, tuple[float, float, float, float]] = {}
|
|
boxes_loop: dict[int, tuple[float, float, float, float]] = {}
|
|
curr_angle = int(self.__devs.aerotech_omega)
|
|
total_range = steps * step_size + 1
|
|
start_angle = curr_angle if curr_angle + total_range < 720 else 0
|
|
end_angle = curr_angle + total_range
|
|
|
|
for angle in range(start_angle, end_angle, step_size):
|
|
logger.debug(f'moving to angle: {angle}')
|
|
rotate_time = time.perf_counter()
|
|
self.__devs.aerotech_omega = angle
|
|
logger.info(f"time to rotate 15 degrees: {time.perf_counter() - rotate_time}")
|
|
|
|
curr_image = cv2.cvtColor(self.camera_image, cv2.COLOR_RGB2BGR)
|
|
if self.sample is not None and self.sample.db_id is not None:
|
|
self.save_screenshot_db(self.sample.db_id, f"fd_{self.sample.db_id}_{angle}deg")
|
|
box_time = time.perf_counter()
|
|
m = self.__mlbox.predict(curr_image, filename=None, preferred_class=(3, 0))
|
|
logger.info(f"time to predict: {time.perf_counter() - box_time}")
|
|
|
|
if not m or not m.box:
|
|
logger.info(f"no box found for angle {angle}")
|
|
self._emit_face_detection_progress({
|
|
"running": True,
|
|
"current_angle_deg": angle,
|
|
"samples": fd.get_samples_out(boxes_face),
|
|
"height_fit": {},
|
|
"area_fit": {},
|
|
})
|
|
continue
|
|
|
|
cls_id = int(m.cls.value)
|
|
x1, y1, x2, y2 = m.box.top_x, m.box.top_y, m.box.bottom_x, m.box.bottom_y
|
|
|
|
self.face_detection_centre_correction(m, tolerance=0.2)
|
|
|
|
if cls_id == 3:
|
|
boxes_face[angle] = (x1, y1, x2, y2)
|
|
logger.info(f"accepted box at angle {angle}, cls={cls_id}, box={(x1, y1, x2, y2)}")
|
|
elif cls_id == 0:
|
|
boxes_loop[angle] = (x1, y1, x2, y2)
|
|
logger.info(f"accepted box at angle {angle}, cls={cls_id}, box={(x1, y1, x2, y2)}")
|
|
else:
|
|
logger.debug(f"ignoring class {cls_id} at angle {angle}")
|
|
|
|
self._emit_face_detection_progress({
|
|
"running": True,
|
|
"current_angle_deg": angle,
|
|
"samples": fd.get_samples_out(boxes_face),
|
|
"height_fit": {},
|
|
"area_fit": {},
|
|
})
|
|
|
|
if not boxes_face and not boxes_loop:
|
|
logger.info("no boxes found")
|
|
result = {"running": False, "samples": [], "height_fit": {}, "area_fit": {}}
|
|
self._emit_face_detection_progress(result)
|
|
return result
|
|
|
|
total_detections = len(boxes_face) + len(boxes_loop)
|
|
face_ratio = len(boxes_face) / total_detections if total_detections > 0 else 0.0
|
|
|
|
if boxes_face and face_ratio >= face_min_ratio:
|
|
boxes = boxes_face
|
|
logger.info(f"using loop_face boxes ({len(boxes_face)}/{total_detections}, ratio={face_ratio:.2f})")
|
|
elif boxes_loop:
|
|
boxes = boxes_loop
|
|
logger.info(
|
|
f"falling back to loop_all boxes ({len(boxes_loop)}/{total_detections}, ratio={1 - face_ratio:.2f})")
|
|
else:
|
|
boxes = boxes_face
|
|
logger.info(f"using loop_face boxes (only source, {len(boxes_face)} entries)")
|
|
|
|
best_fit_angle_area, area_params = fd.get_flat_face(boxes, start_angle, end_angle, True)
|
|
best_fit_angle_height, height_params = fd.get_flat_face(boxes, start_angle, end_angle, False)
|
|
fit_results = {
|
|
"Area": {"angle": best_fit_angle_area, "params": area_params},
|
|
"Height": {"angle": best_fit_angle_height, "params": height_params},
|
|
}
|
|
logger.info(f"best angle by area: {best_fit_angle_area}")
|
|
logger.info(f"best angle by height: {best_fit_angle_height}")
|
|
flat_face_angle, best_params, best_name = fd.choose_best_fit(fit_results)
|
|
logger.info(f"best params: {best_params}")
|
|
logger.info(f"chosen fit: {best_name}")
|
|
logger.info(f"best angle: {flat_face_angle}")
|
|
self.__devs.aerotech_omega = flat_face_angle
|
|
|
|
samples_out = fd.get_samples_out(boxes)
|
|
logger.info(f"face detection sequence done, samples: {samples_out}")
|
|
|
|
result = {
|
|
"running": False,
|
|
"samples": samples_out,
|
|
"height_fit": {
|
|
"A": height_params["A"],
|
|
"B": height_params["B"],
|
|
"phi_rad": height_params["phi_rad"],
|
|
"C": height_params["C"],
|
|
"best_angle_deg": best_fit_angle_height,
|
|
},
|
|
"area_fit": {
|
|
"A": area_params["A"],
|
|
"B": area_params["B"],
|
|
"phi_rad": area_params["phi_rad"],
|
|
"C": area_params["C"],
|
|
"best_angle_deg": best_fit_angle_area,
|
|
},
|
|
}
|
|
self._emit_face_detection_progress(result)
|
|
return result
|
|
|
|
|
|
def __loop_center_sequence(self, sample_id: int | None = None, trace_all_alc_moves: bool = False) -> bool:
|
|
self.__set_state(BeamlineStateEnum.SampleAlignment)
|
|
self.__devs.lamp_light = 2.5
|
|
|
|
found_classes_count: dict[int, int] = {0:0, 1:0, 2:0, 3:0}
|
|
|
|
try:
|
|
self.__cfg.zoom_mode = ZoomModeEnum.LoopCenter
|
|
#zoom_settings = self.__cfg.zoom_settings.z
|
|
|
|
for zoom_iter, zoom_value in enumerate([200]):
|
|
#exposure = zoom_settings[zoom_value].exposure
|
|
#gain = zoom_settings[zoom_value].gain
|
|
max_attempt = 2
|
|
attempt = 0
|
|
|
|
base_angles = (0, 90) if (zoom_iter % 2 == 0) else (90, 0)
|
|
if sample_id is not None:
|
|
logger.info(f"submitting to db loop center sequence for sample {sample_id}, zoom={zoom_value}")
|
|
self.save_screenshot_db(sample_id, f"pre_alc")
|
|
|
|
while attempt < max_attempt:
|
|
#self.__devs.samcam_settings = SampleCameraSettings(exposure=exposure, gain=gain)
|
|
#exp = int(exposure * 1000)
|
|
#gn = int(gain)
|
|
self.zoom = zoom_value
|
|
|
|
found_flag = False
|
|
found_angle: int | None = None
|
|
targets_found_this_attempt = 0
|
|
|
|
for angle in base_angles:
|
|
logger.debug(f"Moving to new omega: {angle}")
|
|
time_to_move_aerotech= time.perf_counter()
|
|
self.__devs.aerotech_omega = angle
|
|
logger.info(f"time to move: {time.perf_counter()-time_to_move_aerotech}")
|
|
|
|
filename = f"{sample_id}_{angle}_{zoom_value:.0f}" if sample_id is not None else None
|
|
|
|
try:
|
|
self.save_screenshot(filename=f'{sample_id}_{angle}')
|
|
target, cls, classes = self.__ml_loop_centre_box(sample_id, filename)
|
|
except Exception as e:
|
|
logger.error(f"Error getting ML box for angle {angle}")
|
|
logger.error(f"Exception: {e}")
|
|
target, cls, classes = None, None, None
|
|
|
|
if target is None:
|
|
logger.debug("no target found")
|
|
continue
|
|
|
|
if classes:
|
|
for c in classes:
|
|
found_classes_count[int(c)] += 1
|
|
logger.debug(f"classes found: {classes}")
|
|
logger.debug(f"class found: {cls}")
|
|
if cls is not None and cls != 1:
|
|
targets_found_this_attempt += 1
|
|
found_flag = True
|
|
found_angle = angle
|
|
|
|
time_to_move_smargon = time.perf_counter()
|
|
self.__devs.smargon_pos = target
|
|
self.__devs.smargon_wait(60)
|
|
logger.info(f"time to move smargon: {time.perf_counter() - time_to_move_smargon}")
|
|
if sample_id is not None:
|
|
if trace_all_alc_moves:
|
|
self._append_smargon_trace(
|
|
sample_id=sample_id,
|
|
event=f"alc_move_zoom_{zoom_value:.0f}_angle_{angle}"
|
|
)
|
|
self.save_screenshot_db(sample_id, f"{sample_id}_{angle}_{zoom_value:.0f}")
|
|
|
|
if targets_found_this_attempt == 0:
|
|
logger.error(f"No targets found in this attempt {attempt}")
|
|
raise LoopCenteringFailed
|
|
|
|
else:
|
|
if targets_found_this_attempt >= len(base_angles):
|
|
logger.debug(f"sucessfully found {targets_found_this_attempt} targets in attempt {attempt + 1} ")
|
|
break
|
|
if found_flag is not None and found_angle is not None:
|
|
logger.debug(f"found a target at angle {found_angle} in attempt {attempt + 1}")
|
|
base_angles = (found_angle, found_angle + 45)
|
|
logger.debug(f"new base angles: {base_angles}")
|
|
attempt += 1
|
|
logger.debug(f"attempt {attempt} of {max_attempt}")
|
|
if attempt >= max_attempt:
|
|
logger.error(f"{attempt} exceeds max attempts {max_attempt}")
|
|
raise LoopCenteringFailed
|
|
|
|
#i += 1
|
|
logger.debug("alc success")
|
|
logger.debug(f"current sample: {self.__cfg.current_sample}, sample_id of scan: {sample_id}")
|
|
self.__aare.sample_centered(self.__cfg.current_sample)
|
|
if sample_id is not None:
|
|
logger.info(f"sample {sample_id} centered")
|
|
self.save_screenshot_db(sample_id, f"{sample_id}_centered")
|
|
self._append_smargon_trace(sample_id=sample_id, event="alc_success")
|
|
return True
|
|
|
|
except Exception as e:
|
|
total_detections = sum(found_classes_count.values())
|
|
if total_detections == 0:
|
|
logger.error("ALC: No objects found in any zoom")
|
|
self.__aare.alc_failed(self.sample, alc_comment="No objects detected")
|
|
else:
|
|
self.__aare.alc_failed(
|
|
self.sample,
|
|
alc_comment=(
|
|
"Failed to centre but detected objects - "
|
|
f"Crystal: {found_classes_count.get(2,0)}, "
|
|
f"Loop_face: {found_classes_count.get(3,0)}, "
|
|
f"Loop_all: {found_classes_count.get(0,0)}, "
|
|
f"Pin: {found_classes_count.get(1,0)}"
|
|
),
|
|
)
|
|
logger.error("Failed to centre but detected objects - "
|
|
f"Crystal: {found_classes_count.get(2,0)}, "
|
|
f"Loop_face: {found_classes_count.get(3,0)}, "
|
|
f"Loop_all: {found_classes_count.get(0,0)}, "
|
|
f"Pin: {found_classes_count.get(1,0)}")
|
|
logger.error(traceback.format_exc())
|
|
|
|
logger.error(f"Error in loop centering: {e}")
|
|
return False
|
|
|
|
def auto_loop_center(self, sample_id: int | None = None) -> float:
|
|
"""
|
|
Automatically center the loop using ML-based detection.
|
|
This performs a multi-step sequence including rotation and centering.
|
|
|
|
Args:
|
|
sample_id: Optional database ID of the sample. If None, uses current sample.
|
|
|
|
Returns:
|
|
Time taken for the centering process (seconds)
|
|
|
|
Raises:
|
|
LoopCenteringFailed: If the centering sequence fails
|
|
"""
|
|
start = time.perf_counter()
|
|
try:
|
|
self.__cfg.try_set_busy(timeout=360)
|
|
if sample_id is None:
|
|
sample_id = self.__cfg.current_sample.db_id
|
|
|
|
if not self._execute_loop_centering(sample_id):
|
|
raise LoopCenteringFailed
|
|
|
|
self.__cfg.state_busy = False
|
|
|
|
except Exception as e:
|
|
self.__cfg.zoom_mode = ZoomModeEnum.User
|
|
self.__cfg.state_busy = False
|
|
raise
|
|
|
|
finally:
|
|
self.__cfg.zoom_mode = ZoomModeEnum.User
|
|
|
|
end = time.perf_counter()
|
|
return end - start
|
|
|
|
def _default_screenshot_message(self, sample_id: int) -> str:
|
|
omega_value = self.omega
|
|
zoom_value = self.zoom
|
|
samcam = self.samcam_settings
|
|
return (
|
|
f"sample_id: {sample_id} "
|
|
f"zoom: {zoom_value} "
|
|
f"exp:{samcam.exposure} "
|
|
f"gain:{samcam.gain} "
|
|
f"omega:{omega_value:.2f}"
|
|
)
|
|
|
|
def save_screenshot(self, filename: str):
|
|
#time.sleep(0.2) # Wait 200 ms to ensure camera image is stable
|
|
bgr_image = cv2.cvtColor(self.camera_image, cv2.COLOR_RGB2BGR)
|
|
logger.debug(f"saving screenshot {filename}")
|
|
cv2.imwrite(f"/sls/mx/applications/logs/{filename}.jpg", bgr_image)
|
|
|
|
def save_screenshot_db(self, sample_id: int, filename: str):
|
|
"""
|
|
Capture a screenshot and upload it to the database for a specific sample.
|
|
|
|
Args:
|
|
sample_id: Database ID of the sample.
|
|
filename: Name to give to the uploaded image.
|
|
"""
|
|
#time.sleep(0.2) # Wait 200 ms to ensure camera image is stable
|
|
bgr_image = cv2.cvtColor(self.camera_image, cv2.COLOR_RGB2BGR)
|
|
self.__aare.upload_image(sample_id, filename, bgr_image)
|
|
|
|
def send_screenshot_db(self, filename: str | None = None, message: str | None = None) -> None:
|
|
sample = self.sample
|
|
if sample is None or sample.db_id is None or sample.db_id < 0:
|
|
raise ValueError("No sample with a valid sample_id is mounted.")
|
|
|
|
sample_id = sample.db_id
|
|
bgr_image = cv2.cvtColor(self.camera_image, cv2.COLOR_RGB2BGR)
|
|
|
|
if filename:
|
|
filename = clean_filename(filename)
|
|
pgroup = self.__cfg.pgroup
|
|
if not pgroup:
|
|
raise ValueError("No active pgroup set; cannot save screenshot to photos directory.")
|
|
|
|
photos_dir = Path("/sls/mx/data") / pgroup / "raw" / "photos"
|
|
photos_dir = photos_dir / str(sample_id)
|
|
photos_dir.mkdir(parents=True, exist_ok=True)
|
|
photo_path = photos_dir / f"{filename}.jpeg"
|
|
cv2.imwrite(str(photo_path), bgr_image)
|
|
|
|
upload_name = filename or f"{sample_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
final_message = (message or "").strip() or self._default_screenshot_message(sample_id)
|
|
self.__aare.upload_image(sample_id, upload_name, bgr_image, message=final_message)
|
|
|
|
|
|
@property
|
|
def sample_spreadsheet(self) -> SampleShortInfoList:
|
|
return self.__cfg.spreadsheet
|
|
|
|
@property
|
|
def reference_tools(self) -> SampleShortInfoList:
|
|
return self.__cfg.reference_tools
|
|
|
|
def sample_spreadsheet_user(self, pgroup: str) -> SampleShortInfoList:
|
|
# sample = copy.deepcopy(self.sample_spreadsheet) #switch to copy if too heavy!
|
|
# sample.s = list(filter(lambda x: x.user == pgroup, sample.s))
|
|
# return sample
|
|
return SampleShortInfoList(s=[x for x in self.sample_spreadsheet.s if x.user == pgroup])
|
|
|
|
def get_auto_raster_params(self) -> SimpleScanParameters:
|
|
|
|
if self.status.sample is None:
|
|
return SimpleScanParameters(dtz=150, exp_time_s=0.04, transmission=1.0)
|
|
|
|
aaredb_params = self.status.sample.aaredb_params if hasattr(self.status.sample, "aaredb_params") else None
|
|
|
|
if aaredb_params is None:
|
|
return SimpleScanParameters(dtz=150, exp_time_s=0.04, transmission=1.0)
|
|
|
|
params = SimpleScanParameters()
|
|
|
|
# Exposure
|
|
exp = getattr(aaredb_params, 'exposure', None)
|
|
if exp is not None:
|
|
params.exp_time_s = exp
|
|
else:
|
|
params.exp_time_s = 0.04 # Default
|
|
|
|
# Transmission
|
|
trans = getattr(aaredb_params, 'transmission', None)
|
|
if trans is not None:
|
|
logger.debug(f"transmission: {trans}")
|
|
params.transmission = trans / 100.0 if trans > 1.0 else trans
|
|
else:
|
|
params.transmission = 1.0 # Default
|
|
|
|
# Resolution and DTZ
|
|
res = getattr(aaredb_params, 'targetresolution', None)
|
|
if res is not None:
|
|
logger.debug(f"resolution: {res}")
|
|
try:
|
|
params.dtz = self.diffraction_geometry.calc_dtz_mm(res)
|
|
logger.debug(f"dtz: {params.dtz}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to calculate dtz for resolution {res}: {e}")
|
|
params.dtz = 150 # Fallback default
|
|
|
|
# Adjust exposure time based on resolution
|
|
if res <= 1.5:
|
|
params.exp_time_s = 0.02
|
|
elif 1.5 < res <= 3.0:
|
|
params.exp_time_s = 0.04
|
|
else:
|
|
params.exp_time_s = 0.08
|
|
else:
|
|
params.dtz = 150
|
|
params.exp_time_s = 0.04
|
|
|
|
return params
|
|
|
|
def get_collection_params(self, prefer_smart: bool = False) -> tuple[SimpleScanParameters, str]:
|
|
spreadsheet_params, file_prefix = self.spreadsheet_params()
|
|
logger.debug(f"spreadsheet_params: {spreadsheet_params}")
|
|
smart_params = self.__cfg.auto_params
|
|
default_params = SimpleScanParameters(exp_time_s=0.04, dtz=110, incr_omega_deg=0.2)
|
|
#if file_prefix is not None:
|
|
# default_params.file_prefix = file_prefix
|
|
#self.__aare.send_msg_to_db(self.sample,event_type=SampleEventType(''), comment=f'smart_params: {smart_params}')
|
|
if prefer_smart:
|
|
if smart_params:
|
|
#if file_prefix:
|
|
# smart_params.file_prefix = file_prefix
|
|
return smart_params, "smart_params"
|
|
if spreadsheet_params:
|
|
return spreadsheet_params, "spreadsheet_params"
|
|
else:
|
|
if spreadsheet_params:
|
|
return spreadsheet_params, "spreadsheet_params"
|
|
if smart_params:
|
|
#if file_prefix:
|
|
# smart_params.file_prefix = file_prefix
|
|
return smart_params, "smart_params"
|
|
|
|
return default_params, "defaults"
|
|
|
|
def _end_operation(self, start) -> float:
|
|
"""
|
|
End an operation and return elapsed time.
|
|
|
|
Returns:
|
|
(elapsed_seconds, end_time_perf_counter)
|
|
"""
|
|
self.__cfg.state_busy = False
|
|
end = time.perf_counter()
|
|
return end - start
|
|
|
|
def measure(self, sample: SampleShortInfo) -> float:
|
|
"""
|
|
Main measurement sequence for a given sample.
|
|
Includes mounting, loop centering, face detection, auto-raster, and data collection.
|
|
|
|
Args:
|
|
sample: SampleShortInfo object containing sample details
|
|
|
|
Returns:
|
|
Total time taken for the measurement (seconds)
|
|
"""
|
|
start = time.perf_counter()
|
|
formatted_date = datetime.now().strftime('%Y%m%d')
|
|
sample_prefix = "{}/{}/{:02d}/{}".format(
|
|
formatted_date,
|
|
sample.puck_name,
|
|
sample.pin,
|
|
sample.sample_name
|
|
)
|
|
|
|
try:
|
|
logger.info(f"setting busy at {time.perf_counter() - start}")
|
|
self.__cfg.try_set_busy(timeout=360)
|
|
logger.info(f"set busy at {time.perf_counter() - start}")
|
|
|
|
if not self._execute_mount_and_prepare(sample):
|
|
logger.error("Failed to mount sample")
|
|
return self._end_operation(start)
|
|
|
|
logger.info(f"mounting done at {time.perf_counter() - start}")
|
|
|
|
# Step 2: Loop centering
|
|
if not self._execute_loop_centering(sample.db_id):
|
|
logger.error("Loop Centering did not succeed")
|
|
return self._end_operation(start)
|
|
|
|
logger.info(f"alc done at {time.perf_counter() - start}")
|
|
|
|
# Step 3: Face detection
|
|
result = self.__face_detection_sequence(steps=7, step_size=30)
|
|
self._emit_face_detection_progress(result)
|
|
logger.info(f"face_detection done at {time.perf_counter() - start}")
|
|
|
|
# Step 4: Auto-center and raster
|
|
hex_string = secrets.token_hex(3)
|
|
raster_params = self.get_auto_raster_params()
|
|
geom = self.sample_geometry
|
|
|
|
raster_grid = RasterGridRequest(
|
|
exp_time_s=raster_params.exp_time_s,
|
|
file_prefix=sample_prefix + f"_{hex_string}",
|
|
smargon_top_left=SmargonCoordinate(),
|
|
n_x=1,
|
|
n_y=1,
|
|
dtz=raster_params.dtz,
|
|
grid_size_mm=Coordinate(x=geom.beam_size_mm.x * 0.5, y=geom.beam_size_mm.y * 0.5),
|
|
omega_deg=self.omega,
|
|
transmission=raster_params.transmission,
|
|
)
|
|
|
|
raster_result = self._execute_raster_sequence(raster_grid, auto_center=True)
|
|
if raster_result is None:
|
|
logger.error("Raster result was None")
|
|
return self._end_operation(start)
|
|
|
|
logger.info(f"raster scans done at {time.perf_counter() - start}")
|
|
|
|
# Step 5: Data collection (rotation)
|
|
params, source = self.get_collection_params(prefer_smart=False)
|
|
logger.info(f"Using {source} for data collection: {params}")
|
|
|
|
self.__devs.dtz = params.dtz
|
|
start_omega = self.omega
|
|
|
|
rotation_request = RotationScanRequest(
|
|
start_omega_deg=start_omega,
|
|
dtz=params.dtz,
|
|
file_prefix="data/" + sample_prefix + f"_{hex_string}",
|
|
exp_time_s=params.exp_time_s,
|
|
incr_omega_deg=params.incr_omega_deg,
|
|
steps=params.steps,
|
|
transmission=params.transmission,
|
|
)
|
|
|
|
rotation_result = self._execute_rotation_sequence(rotation_request)
|
|
if rotation_result is None:
|
|
logger.error("Rotation result was None")
|
|
return self._end_operation(start)
|
|
|
|
logger.info(f"rotation done at {time.perf_counter() - start}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in measure: {e}")
|
|
self._handle_operation_error("measure", sample, e, error_type="generic")
|
|
|
|
finally:
|
|
logger.debug(f"finally at {time.perf_counter() - start}")
|
|
|
|
return self._end_operation(start)
|
|
|
|
def __set_state(self, target: BeamlineStateEnum):
|
|
"""__set_state assumes that beamline is already in busy state
|
|
it will apply a proper transformation and change state afterward
|
|
specifically:
|
|
1. If target state is maintenance, just go there
|
|
2. If target state is same as current, nothing will happen
|
|
3. If target state cannot be reached, exception is raised and current state is kept
|
|
4. If exception is raised during transformation, state is set to maintenance
|
|
5. If transformation goes OK, target state is set
|
|
|
|
Busy state will be cleared only, if exception is raised. """
|
|
|
|
curr_state = self.__cfg.state
|
|
|
|
if not self.__cfg.state_busy:
|
|
raise Exception("Beamline should be busy")
|
|
|
|
if target == BeamlineStateEnum.Maintenance:
|
|
self.__cfg.state = BeamlineStateEnum.Maintenance
|
|
elif target != curr_state:
|
|
self.__cfg.state = BeamlineStateEnum.Moving
|
|
try:
|
|
match curr_state:
|
|
case BeamlineStateEnum.Maintenance:
|
|
if target == BeamlineStateEnum.SampleExchange:
|
|
workflows.m2se(self.__devs, self.__cfg)
|
|
else:
|
|
raise TransformationInvalidException()
|
|
case BeamlineStateEnum.SampleExchange:
|
|
if target == BeamlineStateEnum.SampleAlignment:
|
|
workflows.se2sa(self.__devs, self.__cfg)
|
|
elif target == BeamlineStateEnum.BeamLocation:
|
|
workflows.se2sa(self.__devs, self.__cfg)
|
|
workflows.sa2bl(self.__devs, self.__cfg)
|
|
else:
|
|
raise TransformationInvalidException()
|
|
case BeamlineStateEnum.DewarTransfer:
|
|
if target == BeamlineStateEnum.SampleAlignment:
|
|
workflows.dh2sa(self.__devs, self.__cfg)
|
|
else:
|
|
raise TransformationInvalidException()
|
|
case BeamlineStateEnum.DataCollection:
|
|
if target == BeamlineStateEnum.SampleAlignment:
|
|
workflows.dc2sa(self.__devs, self.__cfg)
|
|
elif target == BeamlineStateEnum.RobotSampleExchange:
|
|
workflows.dc2rse(self.__devs, self.__cfg)
|
|
else:
|
|
raise TransformationInvalidException()
|
|
case BeamlineStateEnum.BeamLocation:
|
|
if target == BeamlineStateEnum.SampleAlignment:
|
|
workflows.bl2sa(self.__devs, self.__cfg)
|
|
elif target == BeamlineStateEnum.SampleExchange:
|
|
workflows.bl2sa(self.__devs, self.__cfg)
|
|
workflows.sa2se(self.__devs, self.__cfg)
|
|
else:
|
|
raise TransformationInvalidException()
|
|
case BeamlineStateEnum.SampleAlignment:
|
|
if target == BeamlineStateEnum.DewarTransfer:
|
|
workflows.sa2dh(self.__devs, self.__cfg)
|
|
elif target == BeamlineStateEnum.SampleExchange:
|
|
workflows.sa2se(self.__devs, self.__cfg)
|
|
elif target == BeamlineStateEnum.RobotSampleExchange:
|
|
workflows.sa2rse(self.__devs, self.__cfg)
|
|
elif target == BeamlineStateEnum.DataCollection:
|
|
workflows.sa2dc(self.__devs, self.__cfg)
|
|
elif target == BeamlineStateEnum.XrayFluorescence:
|
|
workflows.sa2xrf(self.__devs, self.__cfg)
|
|
elif target == BeamlineStateEnum.BeamLocation:
|
|
workflows.sa2bl(self.__devs, self.__cfg)
|
|
else:
|
|
raise TransformationInvalidException()
|
|
case BeamlineStateEnum.XrayFluorescence:
|
|
if target == BeamlineStateEnum.SampleAlignment:
|
|
workflows.xrf2sa(self.__devs, self.__cfg)
|
|
else:
|
|
raise TransformationInvalidException()
|
|
case BeamlineStateEnum.RobotSampleExchange:
|
|
if target == BeamlineStateEnum.SampleAlignment:
|
|
workflows.rse2sa(self.__devs, self.__cfg)
|
|
else:
|
|
raise TransformationInvalidException()
|
|
self.__cfg.state = target
|
|
except TransformationInvalidException as e:
|
|
self.__cfg.state = curr_state
|
|
self.__cfg.state_busy = False
|
|
raise
|
|
except Exception as e:
|
|
self.__cfg.state = BeamlineStateEnum.Maintenance
|
|
self.__cfg.state_busy = False
|
|
raise
|
|
|
|
@property
|
|
def shutter(self) -> bool:
|
|
return self.__devs.shutter
|
|
|
|
@shutter.setter
|
|
def shutter(self, v: bool):
|
|
self.__devs.shutter = v
|
|
|
|
@property
|
|
def diffraction_geometry(self) -> DiffractionGeometry:
|
|
det_cfg = self.__jfjoch.detector()
|
|
return DiffractionGeometry(
|
|
energy_keV=self.__devs.energy_kev,
|
|
dtz_mm=self.__devs.dtz,
|
|
detector_size_pxl=(det_cfg.width, det_cfg.height),
|
|
pixel_size_mm=det_cfg.pixel_size_mm,
|
|
beam_center_pxl=self.__cfg.beam_center,
|
|
detector_description=det_cfg.description,
|
|
detector_serial_number=det_cfg.serial_number,
|
|
poni_rot1_rad=-0.001396263,
|
|
poni_rot2_rad=-0.003839724,
|
|
)
|
|
|
|
@property
|
|
def beamline_status(self) -> BeamlineStatus:
|
|
return BeamlineStatus(
|
|
ring_current_mA=self.__devs.ring_current,
|
|
front_light=self.front_light,
|
|
back_light=self.back_light,
|
|
cryojet_K=self.__devs.cryojet_temp,
|
|
shutter_open=self.__devs.shutter,
|
|
exp_shutter_open=self.__devs.shutter,
|
|
flux_ph_s=self.__devs.full_flux,
|
|
sample_camera=self.__devs.samcam_settings,
|
|
name=self.__bl,
|
|
transmission=self.__devs.transmission,
|
|
zoom=self.__devs.zoom,
|
|
commissioning_mode=self.__cfg.commissioning_mode,
|
|
dtz_min=20,
|
|
dtz_max=1000,
|
|
)
|
|
|
|
def _safe_sample(self) -> tuple[SampleShortInfo | None, bool, str | None]:
|
|
"""
|
|
Return (sample, tell_connected, tell_error) without raising.
|
|
"""
|
|
try:
|
|
return self.sync_current_sample_from_tell(), True, None
|
|
except TellCommunicationError as e:
|
|
return self.__cfg.current_sample, False, str(e)
|
|
except Exception as e:
|
|
# Keep status flowing even if Tell code throws something unexpected
|
|
return self.__cfg.current_sample, False, f"TELL unavailable: {e}"
|
|
|
|
def _aerotech_status(self) -> tuple[bool, str | None]:
|
|
aerotech_ok = True
|
|
aerotech_err: str | None = None
|
|
try:
|
|
_ = self.__devs.aerotech.status()
|
|
except Exception as e:
|
|
aerotech_ok = False
|
|
aerotech_err = f"Cannot connect to Aerotech: {e}"
|
|
return aerotech_ok, aerotech_err
|
|
|
|
def _safe_geom(self) -> tuple[SampleGeometryModel, bool, str | None, bool, str | None]:
|
|
"""
|
|
Return (geom, smargon_connected, smargon_error, aerotech_connected, aerotech_error) without raising.
|
|
Uses a conservative fallback geometry if Smargon access fails.
|
|
"""
|
|
aerotech_connected, smargon_connected = True, True
|
|
aerotech_error, smargon_error = None, None
|
|
try:
|
|
return self.sample_geometry, smargon_connected, smargon_error, aerotech_connected, aerotech_error
|
|
except SmargonCommunicationError as e:
|
|
smargon_error = f"Cannot connect to Smargon: {e}"
|
|
smargon_connected = False
|
|
except AerotechCommunicationError as e:
|
|
aerotech_error = f"Cannot connect to Aerotech: {e}"
|
|
aerotech_connected = False
|
|
except Exception as e:
|
|
smargon_error = f"Safe geometry failed: {e}"
|
|
aerotech_error = f"Safe geometry failed: {e}"
|
|
aerotech_connected = False
|
|
smargon_connected = False
|
|
zoom = self.__devs.zoom
|
|
fallback = SampleGeometryModel(
|
|
beam_location_pxl=self.__cfg.beam_mark_coeff.apply(zoom),
|
|
pixel_in_mm=self.__cfg.pixel_to_mm(zoom),
|
|
omega_deg=0.0,
|
|
smargon=SmargonCoordinate(
|
|
sh_mm=Coordinate(x=0.0, y=0.0, z=0.0),
|
|
phi_deg=0.0,
|
|
chi_deg=0.0,
|
|
),
|
|
beam_size_mm=self.__cfg.beam_size_mm,
|
|
aerotech=Coordinate(x=0.0, y=0.0, z=0.0),
|
|
aerotech_meas=Coordinate(x=0.0, y=0.0, z=0.0),
|
|
)
|
|
return fallback, smargon_connected, smargon_error, aerotech_connected, aerotech_error
|
|
|
|
def _safe_beamline_status(self) -> BeamlineStatus:
|
|
try:
|
|
return self.beamline_status
|
|
except Exception:
|
|
return BeamlineStatus(
|
|
name=self.__bl,
|
|
ring_current_mA=0.0,
|
|
front_light=0.0,
|
|
back_light=0.0,
|
|
cryojet_K=0.0,
|
|
shutter_open=False,
|
|
exp_shutter_open=None,
|
|
flux_ph_s=0.0,
|
|
sample_camera=SampleCameraSettings(gain=0.0, exposure=0.0),
|
|
transmission=None,
|
|
zoom=self.__devs.zoom,
|
|
commissioning_mode=self.__cfg.commissioning_mode,
|
|
dtz_min=20,
|
|
dtz_max=1000,
|
|
)
|
|
|
|
def _safe_diffraction_geometry(self) -> DiffractionGeometry:
|
|
try:
|
|
return self.diffraction_geometry
|
|
except Exception:
|
|
# Must satisfy pydantic constraints in DiffractionGeometry
|
|
return DiffractionGeometry(
|
|
energy_keV=12.4,
|
|
dtz_mm=150.0,
|
|
detector_size_pxl=(1, 1),
|
|
pixel_size_mm=0.15,
|
|
beam_center_pxl=(0.0, 0.0),
|
|
detector_description="unavailable",
|
|
detector_serial_number="unavailable",
|
|
poni_rot1_rad=0.0,
|
|
poni_rot2_rad=0.0,
|
|
)
|
|
|
|
@property
|
|
def status(self) -> DAQStatusModel:
|
|
safe_sample, tell_ok, tell_err = self._safe_sample()
|
|
safe_geom, smargon_ok, smargon_err, aerotech_ok, aerotech_err = self._safe_geom()
|
|
|
|
|
|
return DAQStatusModel(
|
|
state=self.state,
|
|
busy=self.busy,
|
|
geom=safe_geom,
|
|
bl=self._safe_beamline_status(),
|
|
sample=safe_sample,
|
|
session=SessionStatus(
|
|
current_pgroup=self.__cfg.pgroup,
|
|
session=self.__cfg.session_state(0), # 0 is dummy session
|
|
staff=False
|
|
),
|
|
diffraction=self._safe_diffraction_geometry(),
|
|
box=self.__saved_box,
|
|
last_best_res=self.__cfg.last_best_res,
|
|
last_best_b_factor=self.__cfg.last_best_b_factor,
|
|
crystal_size=self.__cfg.crystal_size,
|
|
tell_connected=tell_ok,
|
|
tell_error=tell_err,
|
|
smargon_connected=smargon_ok,
|
|
smargon_error=smargon_err,
|
|
aerotech_connected=aerotech_ok,
|
|
aerotech_error=aerotech_err,
|
|
)
|
|
|
|
def cancel(self):
|
|
if self.__cfg.state == BeamlineStateEnum.DataCollection:
|
|
self.__devs.aerotech.cancel()
|
|
self.__jfjoch.cancel()
|
|
|
|
def anneal(self, time_s: float):
|
|
self.__cfg.set_busy(BeamlineStateEnum.SampleAlignment)
|
|
try:
|
|
self.__devs.anneal(time_s)
|
|
finally:
|
|
self.__cfg.state_busy = False
|
|
|
|
def fluorimeter_take_spectrum(self, fm: FluorescenceSpectrumParameterModel) -> FluorescenceSpectrumOutputModel:
|
|
self.__cfg.try_set_busy(timeout=360)
|
|
|
|
try:
|
|
self.__set_state(BeamlineStateEnum.XrayFluorescence)
|
|
|
|
if fm.transmission is not None:
|
|
self.__devs.transmission = fm.transmission
|
|
|
|
# TODO: Fill
|
|
|
|
self.__set_state(BeamlineStateEnum.SampleAlignment)
|
|
self.__cfg.state_busy = False
|
|
return None
|
|
except Exception as e:
|
|
self.__set_state(BeamlineStateEnum.SampleAlignment)
|
|
self.__cfg.state_busy = False
|
|
raise
|