wip
CI for debye_bec / test (push) Successful in 1m21s
CI for debye_bec / test (pull_request) Successful in 1m20s

This commit is contained in:
2026-09-09 10:11:43 +02:00
parent 6d5adc4205
commit 578be60a2d
8 changed files with 634 additions and 334 deletions
@@ -6,11 +6,12 @@ from bisect import bisect_right
import xraydb
from bec_lib import bec_logger
from ophyd_devices import CompareStatus
from ...devices.absorber import STATUS as ABS_STATUS
from ...devices.eh_shutter import STATUS as EH_PH_STATUS
from ...devices.ionization_chambers.ionization_chamber_enums import AmplifierEnable
from ...devices.nidaq.nidaq_enums import NidaqState
from ...devices.nidaq.nidaq_enums import EpicsMode, NidaqState
from ...devices.op_shutter import STATUS as OP_PH_STATUS
logger = bec_logger.logger
@@ -21,10 +22,12 @@ EMAX = 200
MIN_RING_CURRENT = 5 # Minimum ring current to use auto-gain
NOMINAL_RING_CURRENT = 400 # Nominal ring current of SLS2
TIMEOUT_PUT_PV = 5 # Timeout to set a PV
MONO_VELOCITY = 20 # Move velocity in deg/s
TIMEOUT_MONO_PV = 5 # Timeout to set a PV on the mono
TIMEOUT_MONO_MOVE = 30 # Timeout to finish a movement on the mono
LINKED_CHANNELS = {"ic0", "ic1"}
AVAILABLE_GAINS = [1e6, 1e7, 5e7, 1e8, 1e9] # ascending order
MIN_SIGNAL = 0.05 # Minimum signal to count as valid signal
FULL_SCALE_V = 10.0 # NIDAQ AI full-scale range
@@ -58,6 +61,10 @@ class AutoGain:
"""Start the auto-gain sequence. Measure the signals of the specified
amplifiers and set the gains accordingly. Makes sure there is actually beam available.
Note:
If 'ic0' and 'ic1' are in the amplifier argument, the same gain will be assigned
to both amplifiers to get the best glitch removal during processing later.
Args:
element(str): Element which defines the energy at which the gain will be set, e.g. 'Cu'
edge(str): Corresponding edge, e.g. 'L1'
@@ -155,11 +162,16 @@ class AutoGain:
init_vel = self.dev.mo1_bragg.velocity.get()
logger.info(f"Move mono to start of {emin} eV")
self.dev.mo1_bragg.velocity.put(MONO_VELOCITY)
status = CompareStatus(self.dev.mo1_bragg.velocity, MONO_VELOCITY)
status.wait(TIMEOUT_PUT_PV)
status = self.dev.mo1_bragg.move(emin)
status.wait(TIMEOUT_MONO_MOVE)
# Set NIDAQ to max mode
# TODO implement
self.dev.nidaq.epics_mode.put(EpicsMode.MAX)
status = CompareStatus(self.dev.nidaq.epics_mode, EpicsMode.MAX)
status.wait(timeout=TIMEOUT_PUT_PV)
# Set gains to lowest gain
for name, ch in active_channels.items():
@@ -180,15 +192,14 @@ class AutoGain:
raise AutoGainError("Ring current dropped to 0 mA right before measurement")
# Scan range, recording the peak NIDAQ signal per channel
status = self.dev.mo1_bragg.velocity.put(MONO_VELOCITY)
status.wait(TIMEOUT_MONO_PV)
self.dev.mo1_bragg.move(emax).wait(timeout=TIMEOUT_MONO_MOVE)
status.wait(TIMEOUT_MONO_MOVE)
for name, ch in active_channels.items():
data[name] = max(data[name], ch["signal"].get())
# Rest max values of NIDAQ signals
# TODO implement
self.dev.nidaq.epics_max_reset.put(True)
time.sleep(0.1)
# Measure current ring current again
ring_current_2 = self._get_ring_current()
@@ -202,52 +213,83 @@ class AutoGain:
# Choose gain per channel based on the max signal recorded during the scan
remeasure = False
processed = set()
for name, ch in active_channels.items():
raw_signal = data[name]
logger.info(f"Raw signal for self.device {name} is {raw_signal} V")
if comp_ring_current:
raw_signal = raw_signal * NOMINAL_RING_CURRENT / ring_current
logger.info(f"Compensate for ring current, new raw signal is {raw_signal} V")
if raw_signal < MIN_SIGNAL:
logger.info(f"Raw signal for self.device {name} is below {MIN_SIGNAL}")
# Choose next gain to be 100x the current gain, or if this gain does not exist,
# choose the next smaller one
if ch["gain"] == AVAILABLE_GAINS[-1]:
if name in processed:
continue
# ic0/ic1 must always end up with the same gain -> treat whichever of
# them are present (one or both) as a single group for this decision
if name in LINKED_CHANNELS:
group_names = [n for n in LINKED_CHANNELS if n in active_channels]
else:
group_names = [name]
group_signals = []
for gname in group_names:
gsig = data[gname]
logger.info(f"Raw signal for device {gname} is {gsig} V")
if comp_ring_current:
gsig = gsig * NOMINAL_RING_CURRENT / ring_current
logger.info(f"Compensate for ring current, new raw signal is {gsig} V")
group_signals.append(gsig)
# worst case: the weakest signal drives "need more gain",
# the strongest signal drives "don't saturate"
raw_signal_min = min(group_signals)
raw_signal_max = max(group_signals)
gain_ref = ch[
"gain"
] # linked channels always share gain, so any member's value works
label = "/".join(group_names)
if raw_signal_min < MIN_SIGNAL:
logger.info(f"Raw signal for {label} is below {MIN_SIGNAL}")
if gain_ref == AVAILABLE_GAINS[-1]:
logger.warning(
f"Amplifier of {name} at highest gain {ch['gain']} and still not"
f"Amplifier of {label} at highest gain {gain_ref} and still not"
+ f" measured signal above {MIN_SIGNAL}"
)
else:
next_gain = AVAILABLE_GAINS[
bisect_right(AVAILABLE_GAINS, ch["gain"] * 100) - 1
bisect_right(AVAILABLE_GAINS, gain_ref * 100) - 1
]
ch["self.dev"].set_gain(next_gain)
logger.info(
f"Setting gain of self.device {name} to {next_gain:.0e} and remeasure"
)
for gname in group_names:
active_channels[gname]["dev"].set_gain(next_gain)
active_channels[gname]["gain"] = next_gain
logger.info(f"Setting gain of {label} to {next_gain:.0e} and remeasure")
remeasure = True
else:
gain = max(
(
g
for g in AVAILABLE_GAINS
if raw_signal / ch["gain"] * g <= FULL_SCALE_V * SAFETY_MARGIN
if raw_signal_max / gain_ref * g <= FULL_SCALE_V * SAFETY_MARGIN
),
default=min(AVAILABLE_GAINS),
)
ch["self.dev"].set_gain(gain)
logger.info(f"Calculated final gain for {name} of {gain:.0e}")
for gname in group_names:
active_channels[gname]["dev"].set_gain(gain)
active_channels[gname]["gain"] = gain
logger.info(f"Calculated final gain for {label} of {gain:.0e}")
processed.update(group_names)
# Wait for mono to return to start position
status.wait(TIMEOUT_MONO_MOVE)
# Reset NIDAQ to mean mode
# TODO implement
self.dev.nidaq.epics_mode.put(EpicsMode.MEAN)
status = CompareStatus(self.dev.nidaq.epics_mode, EpicsMode.MEAN)
status.wait(timeout=TIMEOUT_PUT_PV)
# Wait for mono to move to initial position and reset velocity
status = self.dev.mo1_bragg.move(init_pos)
status.wait(TIMEOUT_MONO_MOVE)
self.dev.mo1_bragg.velocity.put(init_vel)
status = CompareStatus(self.dev.mo1_bragg.velocity, init_vel)
status.wait(timeout=TIMEOUT_PUT_PV)
def _get_ring_current(self) -> float:
ring_current = 0
@@ -1,4 +1,6 @@
import builtins
import re
import threading
from pathlib import Path
from typing import Literal, cast
@@ -10,6 +12,7 @@ from xrt.backends.raycing.physconsts import AVOGADRO, CHeVcm
from . import parameters as bl
from .beamline import get_beamline_id
from .motion_worker import MotionWorker
from .types import BeamlineId, ConfigDict, DataDict, SurfaceDict
H = 6.62606957e-34
@@ -22,18 +25,65 @@ OFFSET_FILE_X10DA = Path(__file__).with_name("x10da_offsets.yaml")
logger = bec_logger.logger
"""
The idea is to move the core logic of digital_twin to this file. Only keep the gui elements in the widget.
This way, the scheduler widget can access digital twin without loading the GUI (GUI is still needed for the item creation, but not the item execution)
Scheduler will extract assistant inputs (get_assistant_config) during item creation
Scheduler will use digital_twin.calculate_positons to calculate positions and digital_twin.move_all to move the motors
"""
class _ExclusiveGroupManager:
"""Coordinates one exclusive group: only motors from the same
subgroup may be active at once; a different subgroup must wait until
the active one fully finishes."""
def __init__(self):
self._cond = threading.Condition()
self._active_subgroup = None
self._active_count = 0
def acquire(self, subgroup_id):
with self._cond:
while self._active_subgroup is not None and self._active_subgroup != subgroup_id:
self._cond.wait()
self._active_subgroup = subgroup_id
self._active_count += 1
def release(self):
with self._cond:
self._active_count -= 1
if self._active_count == 0:
self._active_subgroup = None
self._cond.notify_all()
# Each entry is one exclusive group, made of subgroups. Motors in the same
# subgroup may move concurrently; motors in different subgroups of the
# same group may not. Motors not listed here move completely freely.
# Example from below: Each listed cm_* motor needs to move alone, but
# sldi_gapx and sldi_gapy can move together. sldi_gapx can move together
# with ot_try
EXCLUSIVE_GROUPS: list[list[list[str]]] = [
[["sldi_gapx", "sldi_gapy"], ["sldi_centerx", "sldi_centery"]],
[["cm_trx"], ["cm_roty"], ["cm_try"], ["cm_rotx"], ["cm_rotz"]],
[["fm_trx"], ["fm_roty"], ["fm_try"], ["fm_rotx"], ["fm_rotz"]],
[["sl1_gapx", "sl1_gapy"], ["sl1_centerx", "sl1_centery"]],
[["sl2_gapx", "sl2_gapy"], ["sl2_centerx", "sl2_centery"]],
[["ot_try"], ["ot_rotx"]],
]
class DigitalTwinCoreError(Exception):
"""DigitalTwinCore specific error"""
class DigitalTwinCore:
def __init__(self):
logger.info("This is the digital twin from the ipython client!")
dev = builtins.__dict__.get("dev")
bec = builtins.__dict__.get("bec")
if dev is None:
raise DigitalTwinCoreError("Did not get dev")
if bec is None:
raise DigitalTwinCoreError("Did not get bec")
self.dev = dev
self.bec = bec
self.beamline = get_beamline_id()
self.offset_file = Path()
match self.beamline:
@@ -44,10 +94,69 @@ class DigitalTwinCore:
self.offsets = {}
self.load_offsets()
def _exclusive_group_key(self, motor: str):
"""Return (group_index, subgroup_index) for `motor`, or (None, None)
if it isn't part of any exclusive group and can move freely."""
for gi, group in enumerate(EXCLUSIVE_GROUPS):
for si, subgroup in enumerate(group):
if motor in subgroup:
return gi, si
return None, None
def move_with_config(self, config):
"""
Move all motors in `config` to their target positions, all starting at
once. Motors are free to move concurrently unless they belong to the
same exclusive group (see `EXCLUSIVE_GROUPS`) but different subgroups,
in which case one subgroup must fully finish before another starts.
Returns:
dict: {"success": True, "moved": [...]} if every motor completed
without error, else {"success": False, "moved": [...],
"failures": {motor: error}}.
"""
positions = self.calc_positions(self.beamline, config)
positions = self.apply_offsets(positions, nested_config=True)
logger.info(f"Would now move to these positions: {positions}")
managers: dict[int, _ExclusiveGroupManager] = {}
for motor in positions:
gi, _ = self._exclusive_group_key(motor)
if gi is not None:
managers.setdefault(gi, _ExclusiveGroupManager())
moved: list[str] = []
failures: dict[str, Exception | str] = {}
results_lock = threading.Lock()
def run_motor(motor: str, target: float):
gi, si = self._exclusive_group_key(motor)
manager = managers.get(gi) if gi is not None else None
if manager:
manager.acquire(si)
try:
worker = MotionWorker(self.beamline, self.dev, motor, target)
worker.run() # blocks this thread until finished or errored
with results_lock:
if worker.last_exception is not None:
failures[motor] = worker.last_exception
else:
moved.append(motor)
finally:
if manager:
manager.release()
threads = [
threading.Thread(target=run_motor, args=(motor, pos["value"]), daemon=True)
for motor, pos in positions.items()
]
for t in threads:
t.start()
for t in threads:
t.join()
if failures:
return {"success": False, "moved": moved, "failures": failures}
return {"success": True, "moved": moved}
def load_offsets(self):
if self.offsets == {}:
@@ -255,7 +364,7 @@ class DigitalTwinCore:
## Focusing Mirror
p = bl.fm.center[1]
q = cfg["smpl"] - bl.fm.center[1]
q = (cfg["ot_es1_trz"] + cfg["es1man_trz"]) - bl.fm.center[1]
f = (p * q) / (p + q) # focal length
# Bender radius
@@ -350,9 +459,22 @@ class DigitalTwinCore:
pos["ot_rotx"] = {"value": ot_pitch * 1e3}
# TRZ ES1
ot_es1_trz = cfg["smpl"]
ot_es1_trz = cfg["ot_es1_trz"]
pos["ot_es1_trz"] = {"value": ot_es1_trz}
# Sample manipulator TRZ
es1man_trz = cfg["es1man_trz"]
if es1man_trz is None:
raise ValueError("es1man_trz is None")
pos["es1man_trz"] = {"value": es1man_trz}
# TRZ ES2
smpl_to_xrd = cfg["smpl_to_xrd"]
if smpl_to_xrd is None:
raise ValueError("smpl_to_xrd is None")
ot_es2_trz = smpl_to_xrd + ot_es1_trz + es1man_trz - 32
pos["ot_es2_trz"] = {"value": ot_es2_trz}
# ES0 exit window
pos["es0wi_try"] = {
"value": 5
@@ -930,6 +1052,7 @@ class DigitalTwinCore:
beam["y"].append(bl.sourceHeight)
beam["x"].append(bl.cm.center[1]) # CM
beam["y"].append(bl.sourceHeight)
smpl = cfg["ot_es1_trz"] + cfg["es1man_trz"]
if cfg["mo1_mode"] == "Monochromatic":
diag = bl.mo1.xtalGap[0] / np.sin(cfg["mo1_bragg"]) # Calculations for Mono
dy = diag * np.sin(2 * (cfg["cm_pitch"] + cfg["mo1_bragg"]))
@@ -951,23 +1074,23 @@ class DigitalTwinCore:
+ np.tan(2 * cfg["cm_pitch"]) * (bl.fm.center[1] - bl.cm.center[1] - dz)
+ dy
)
beam["x"].append(cfg["smpl"]) # Experiment
beam["x"].append(smpl) # Experiment
beam["y"].append(
bl.sourceHeight
+ np.tan(2 * cfg["cm_pitch"]) * (bl.fm.center[1] - bl.cm.center[1] - dz)
+ dy
+ np.tan(2 * (cfg["cm_pitch"] - cfg["fm_rotx"])) * (cfg["smpl"] - bl.fm.center[1])
+ np.tan(2 * (cfg["cm_pitch"] - cfg["fm_rotx"])) * (smpl - bl.fm.center[1])
)
elif cfg["mo1_mode"] == "Pinkbeam":
beam["x"].append(bl.fm.center[1]) # FM
beam["y"].append(
bl.sourceHeight + np.tan(2 * cfg["cm_pitch"]) * (bl.fm.center[1] - bl.cm.center[1])
)
beam["x"].append(cfg["smpl"]) # Experiment
beam["x"].append(smpl) # Experiment
beam["y"].append(
bl.sourceHeight
+ np.tan(2 * cfg["cm_pitch"]) * (bl.fm.center[1] - bl.cm.center[1])
+ np.tan(2 * (cfg["cm_pitch"] - cfg["fm_rotx"])) * (cfg["smpl"] - bl.fm.center[1])
+ np.tan(2 * (cfg["cm_pitch"] - cfg["fm_rotx"])) * (smpl - bl.fm.center[1])
)
return beam
@@ -0,0 +1,269 @@
"""Move an axis through BEC"""
import threading
import time
from typing import Callable
from bec_lib import bec_logger
# pylint: disable=E0402
from ....devices.absorber import STATUS as ABS_STATUS
from .types import BeamlineId
logger = bec_logger.logger
class MotionWorker:
"""
Executes motion on the specified motor and includes some safety during
motion for certain motors. Pure Python / Qt-independent: reports
progress via plain callables instead of Qt signals, so it can run on
any thread (or be unit-tested without a QApplication).
"""
def __init__(
self,
beamline: BeamlineId,
dev,
motor,
target_pos: float,
on_position_changed: Callable[[float], None] | None = None,
on_error: Callable[[], None] | None = None,
on_finished: Callable[[], None] | None = None,
):
self.beamline = beamline
self.dev = dev
self.motor = motor
self._target = target_pos
self._stop_flag = threading.Event()
self.on_position_changed = on_position_changed
self.on_error = on_error
self.on_finished = on_finished
self.last_exception: Exception | None = None
def _emit_position_changed(self, pos: float):
if self.on_position_changed is not None:
self.on_position_changed(pos)
def _emit_error(self):
if self.on_error is not None:
self.on_error()
def _emit_finished(self):
if self.on_finished is not None:
self.on_finished()
def stop(self):
"""Sets the stop flag"""
self._stop_flag.set()
def run(self):
"""Prepares the movement based on the axis (motor)"""
match self.motor:
case (
"sldi_gapx"
| "sldi_gapy"
| "sldi_centerx"
| "sldi_centery"
| "sl1_gapx"
| "sl1_gapy"
| "sl1_centerx"
| "sl1_centery"
| "bm1_try"
| "sl2_gapx"
| "sl2_gapy"
| "sl2_centerx"
| "sl2_centery"
| "bm2_try"
| "es0wi_try"
| "es1_try"
| "es2_try"
| "ot_try"
| "ot_rotx"
| "ot_es1_trz"
| "ot_es2_trz"
| "es1ic0_try"
| "es1ic1_try"
| "es1ic2_try"
):
self.motion()
case "cm_trx":
self.motion(
abs_closed=True,
surveyed_axes=[{"device": self.dev["cm_roty"], "abs_tol": 0.05}],
)
case "cm_roty":
self.motion(
abs_closed=True, surveyed_axes=[{"device": self.dev["cm_trx"], "abs_tol": 0.05}]
)
case "cm_try":
self.motion(
abs_closed=True,
surveyed_axes=[
{"device": self.dev["cm_rotx"], "abs_tol": 0.05},
{"device": self.dev["cm_rotz"], "abs_tol": 0.05},
],
)
case "cm_rotx":
self.motion(
abs_closed=True,
surveyed_axes=[
{"device": self.dev["cm_try"], "abs_tol": 0.05},
{"device": self.dev["cm_rotz"], "abs_tol": 0.05},
],
)
case "cm_rotz":
self.motion(
abs_closed=True,
surveyed_axes=[
{"device": self.dev["cm_try"], "abs_tol": 0.05},
{"device": self.dev["cm_rotx"], "abs_tol": 0.05},
],
)
case "cm_bnd":
if self.beamline == "x01da":
p1 = (
1 / (self.dev.cm_bnd_radius.read()["cm_bnd_radius"]["value"] * 1e3) + 0.0284
) / 2e-6
p2 = (1 / (self._target * 1e3) + 0.0284) / 2e-6
else:
p1 = 541900 / self.dev.cm_bnd_radius.read()["cm_bnd_radius"]["value"] - 32570
p2 = 541900 / self._target - 32570
self._target = p2 - p1
self.motion(relative=True, rb={"device": self.dev["cm_bnd_radius"]})
case "mo1_try" | "mo1_trx" | "mo1_roty":
self.motion(abs_closed=True)
case "mo1_bragg_angle":
if self.beamline == "x01da":
self.motion()
else: # x10da needs to move goniometer
self.motion(alias="mo1_rotx")
case "fm_trx":
self.motion(
abs_closed=True,
surveyed_axes=[{"device": self.dev["fm_roty"], "abs_tol": 0.05}],
)
case "fm_roty":
self.motion(
abs_closed=True, surveyed_axes=[{"device": self.dev["fm_trx"], "abs_tol": 0.05}]
)
case "fm_try":
if self.beamline == "x01da":
abs_tol = 0.05
else: # superxas mirror less stable thus needs higher tolerance
abs_tol = 0.2
self.motion(
abs_closed=True,
surveyed_axes=[
{"device": self.dev["fm_rotx"], "abs_tol": abs_tol},
{"device": self.dev["fm_rotz"], "abs_tol": abs_tol},
],
)
case "fm_rotx":
if self.beamline == "x01da":
abs_tol = 0.05
else: # superxas mirror less stable thus needs higher tolerance
abs_tol = 0.2
self.motion(
abs_closed=True,
surveyed_axes=[
{"device": self.dev["fm_try"], "abs_tol": abs_tol},
{"device": self.dev["fm_rotz"], "abs_tol": abs_tol},
],
)
case "fm_rotz":
if self.beamline == "x01da":
abs_tol = 0.05
else: # superxas mirror less stable thus needs higher tolerance
abs_tol = 0.2
self.motion(
abs_closed=True,
surveyed_axes=[
{"device": self.dev["fm_try"], "abs_tol": abs_tol},
{"device": self.dev["fm_rotx"], "abs_tol": abs_tol},
],
)
case "fm_bnd":
if self.beamline == "x01da":
p1 = (
1 / (self.dev.fm_bnd_radius.read()["fm_bnd_radius"]["value"] * 1e3)
+ 4.28e-5
) / 1.84e-9
p2 = (1 / (self._target * 1e3) + 4.28e-5) / 1.84e-9
else:
p1 = (
593088.7 / self.dev.fm_bnd_radius.read()["fm_bnd_radius"]["value"]
+ 26124.41
)
p2 = 593088.7 / self._target + 26124.41
self._target = p2 - p1
self.motion(relative=True, rb={"device": self.dev["fm_bnd_radius"]})
case _:
logger.warning(f"Motor {self.motor} not integrated in digital twin!")
def motion(
self,
abs_closed: bool = False,
relative: bool = False,
rb=None,
surveyed_axes=None,
alias=None,
):
"""
Moves an axis while surverying a set of axes (if set).
Example surveyed_axes:
[{'device': bec_device_object, 'abs_tol': 0.1},]
Args:
surveyed_axes (list): List of dictionaries of devices
"""
logger.info(f"Move axis {self.motor} to target {self._target}, move_relative={relative}")
try:
if alias:
self.motor = alias
if abs_closed:
if self.dev.abs.status.get() == ABS_STATUS.OPEN:
status = self.dev.abs.close()
status.wait(timeout=5)
if surveyed_axes is not None:
for surv_ax in surveyed_axes:
surv_ax["name"] = surv_ax["device"].dotted_name
surv_ax["old_value"] = surv_ax["device"].read(cached=True)[surv_ax["name"]][
"value"
]
if rb is not None:
rb["name"] = rb["device"].dotted_name
status = self.dev[self.motor].move(self._target, relative=relative)
last_check = time.time()
update_interval = 0.1
while status.status == "RUNNING":
now = time.time()
if time.time() - last_check < update_interval:
time.sleep(0.01)
last_check = now
if self._stop_flag.is_set():
self.dev[self.motor].stop()
self._stop_flag.clear()
if rb is not None:
self._emit_position_changed(rb["device"].read(cached=True)[rb["name"]]["value"])
else:
self._emit_position_changed(
self.dev[self.motor].read(cached=True)[self.motor]["value"]
)
if surveyed_axes is not None:
for surv_ax in surveyed_axes:
fb = surv_ax["device"].read(cached=True)[surv_ax["name"]]["value"]
if abs(fb - surv_ax["old_value"]) > surv_ax["abs_tol"]:
self.dev[self.motor].stop()
self.last_exception = RuntimeError(
f"{surv_ax['name']} deviated beyond tolerance {surv_ax['abs_tol']} during {self.motor} move"
)
self._emit_error()
self._emit_finished()
return
self._emit_finished()
except Exception as e:
logger.error(f"Error during movement of {self.motor}: {e}")
self.last_exception = e
self._emit_error()
self._emit_finished()
@@ -32,7 +32,9 @@ class ConfigDict(TypedDict):
fm_trx (float): FM translation x.
fm_qy (float): FM qy value.
fm_gain_height (int): FM gain height.
smpl (float): Sample value.
ot_es1_trz (float): Position of ES1 table.
es1man_trz (float): Position of Sample Manipulator TRZ
smpl_to_xrd (float): Sample to XRD detector distance
"""
energy: float
@@ -49,7 +51,9 @@ class ConfigDict(TypedDict):
fm_trx: float
fm_qy: None | float
fm_gain_height: int
smpl: float
ot_es1_trz: float
es1man_trz: float
smpl_to_xrd: None | float
class DataDict(TypedDict):
@@ -42,7 +42,6 @@ from .panels.input_panel import InputPanel
from .panels.mover_panel import MoverPanel
from .panels.plots import SideviewPlot, SurfacePlots
from .panels.settings_panel import SettingsPanel
from .widgets.qt_widgets import ComboBox, InputNumberField
logger = bec_logger.logger
@@ -133,11 +132,11 @@ class DigitalTwin(BECWidget, QWidget):
self.input.fm_rotx.value_changed_connect(self.calc_assistant)
self.input.fm_focx.value_changed_connect(self.calc_assistant)
self.input.fm_focy.value_changed_connect(self.calc_assistant)
match self.input.smpl:
case InputNumberField():
self.input.smpl.value_changed_connect(self.calc_assistant)
case ComboBox():
self.input.smpl.activated_connect(self.calc_assistant)
if self.beamline == "x01da":
self.input.ot_es1_trz.value_changed_connect(self.calc_assistant)
else:
self.input.table.activated_connect(self.calc_assistant)
self.input.es1man_trz.value_changed_connect(self.calc_assistant)
self.input.adapt_reality.clicked_connect(self.adapt_reality)
self.settings.load_offsets.clicked_connect(self.load_offsets)
@@ -216,6 +215,7 @@ class DigitalTwin(BECWidget, QWidget):
"fm_rotz",
"bm2_try",
"es0wi_try",
"es1man_trz",
]
if self.beamline == "x01da": # X01DA specific devices
devices.extend(
@@ -226,6 +226,7 @@ class DigitalTwin(BECWidget, QWidget):
"sl2_gapy",
"ot_try",
"ot_es1_trz",
"ot_es2_trz",
]
)
if self.beamline == "x10da": # X10DA specific devices
@@ -344,7 +345,9 @@ class DigitalTwin(BECWidget, QWidget):
self.calc_fm_reflectivity()
self.calc_cm_fm_harm_suppr()
self.calc_fm_ideal_pitch()
case "smpl":
case "ot_es1_trz":
self.calc_fm_ideal_pitch()
case "es1man_trz":
self.calc_fm_ideal_pitch()
self.calc_positions()
self.calc_assistant_sideview()
@@ -380,12 +383,15 @@ class DigitalTwin(BECWidget, QWidget):
assert cm_trx is not None, f"No cm_trx found for given stripe {cm_stripe}!"
assert fm_trx is not None, f"No fm_trx found for given stripe {fm_stripe}!"
match self.input.smpl:
case InputNumberField():
smpl = self.input.smpl.value()
case ComboBox():
table = self.input.smpl.currentText()
smpl = self.core.table_to_smpl_pos(table)
if self.beamline == "x01da":
ot_es1_trz = self.input.ot_es1_trz.value()
smpl_to_xrd = self.input.smpl_to_xrd.value()
else:
table = self.input.table.currentText()
ot_es1_trz = self.core.table_to_smpl_pos(table)
smpl_to_xrd = None
es1man_trz = self.input.es1man_trz.value()
config: ConfigDict = {
"energy": self.input.energy.value(),
@@ -402,7 +408,9 @@ class DigitalTwin(BECWidget, QWidget):
"fm_trx": fm_trx,
"fm_qy": fm_qy,
"fm_gain_height": 1,
"smpl": smpl,
"ot_es1_trz": ot_es1_trz,
"es1man_trz": es1man_trz,
"smpl_to_xrd": smpl_to_xrd,
}
# Apply offsets
@@ -441,13 +449,16 @@ class DigitalTwin(BECWidget, QWidget):
fm_stripe = self.core.fm_trx_to_stripe(-fm_trx)
fm_rotx = self.dev.fm_rotx.read(cached=True)["fm_rotx"]["value"]
fm_rotx_real = 2 * cm_pitch - fm_rotx
es1man_trz = self.dev.es1man_trz.read(cached=True)["es1man_trz"]["value"]
match self.input.smpl:
case InputNumberField():
smpl = self.dev.ot_es1_trz.read(cached=True)["ot_es1_trz"]["value"]
case ComboBox():
table = self.input.smpl.currentText()
smpl = self.core.table_to_smpl_pos(table)
if self.beamline == "x01da":
ot_es1_trz = self.dev.ot_es1_trz.read(cached=True)["ot_es1_trz"]["value"]
ot_es2_trz = self.dev.ot_es2_trz.read(cached=True)["ot_es2_trz"]["value"]
smpl_to_xrd = ot_es2_trz - ot_es1_trz - es1man_trz + 32
else:
table = self.input.table.currentText()
ot_es1_trz = self.core.table_to_smpl_pos(table)
smpl_to_xrd = None
raw = { # Config in SI units!
"energy": mo1_bragg["mo1_bragg"]["value"],
@@ -464,7 +475,9 @@ class DigitalTwin(BECWidget, QWidget):
"fm_trx": fm_trx,
"fm_qy": None,
"fm_gain_height": 1,
"smpl": smpl,
"ot_es1_trz": ot_es1_trz,
"es1man_trz": es1man_trz,
"smpl_to_xrd": smpl_to_xrd,
}
config = cast(ConfigDict, raw)
# logger.info(f'Config created: {config}')
@@ -517,7 +530,9 @@ class DigitalTwin(BECWidget, QWidget):
if self.beamline == "x01da":
self.mover.ot_try.set_feedback(self.dev.ot_try.read(cached=True)["ot_try"]["value"])
self.mover.ot_rotx.set_feedback(self.dev.ot_rotx.read(cached=True)["ot_rotx"]["value"])
self.mover.ot_es1_trz.set_feedback(smpl)
self.mover.ot_es1_trz.set_feedback(ot_es1_trz)
self.mover.ot_es2_trz.set_feedback(ot_es2_trz)
self.mover.es1man_trz.set_feedback(es1man_trz)
self.mover.es0wi_try.set_feedback(
self.dev.es0wi_try.read(cached=True)["es0wi_try"]["value"]
)
@@ -552,8 +567,14 @@ class DigitalTwin(BECWidget, QWidget):
pos["fm_trx"] = self.dev.fm_trx.read(cached=True)["fm_trx"]["value"]
pos["fm_rotx"] = self.dev.fm_rotx.read(cached=True)["fm_rotx"]["value"]
pos["fm_bnd_radius"] = self.dev.fm_bnd_radius.read(cached=True)["fm_bnd_radius"]["value"]
pos["es1man_trz"] = self.dev.es1man_trz.read(cached=True)["es1man_trz"]["value"]
if self.beamline == "x01da":
pos["ot_es1_trz"] = self.dev.ot_es1_trz.read(cached=True)["ot_es1_trz"]["value"]
pos["ot_es2_trz"] = self.dev.ot_es2_trz.read(cached=True)["ot_es2_trz"]["value"]
pos["smpl_to_xrd"] = pos["ot_es2_trz"] - pos["ot_es1_trz"] - pos["es1man_trz"] + 32
else:
table = self.ask_table_selection(self.input.table.currentText())
pos["ot_es1_trz"] = self.core.table_to_smpl_pos(table)
# Removing offsets
pos = self.core.remove_offsets(pos)
@@ -577,14 +598,14 @@ class DigitalTwin(BECWidget, QWidget):
fm_rotx_real = 2 * pos["cm_rotx"] - pos["fm_rotx"]
self.input.fm_rotx.set_number(fm_rotx_real)
match self.input.smpl:
case InputNumberField():
smpl = pos["ot_es1_trz"]
self.input.smpl.set_number(pos["ot_es1_trz"])
case ComboBox():
table = self.ask_table_selection(self.input.smpl.currentText())
smpl = self.core.table_to_smpl_pos(table)
self.input.smpl.set_current_text(table)
if self.beamline == "x01da":
self.input.ot_es1_trz.set_number(pos["ot_es1_trz"])
self.input.smpl_to_xrd.set_number(pos["smpl_to_xrd"])
else:
table = self.ask_table_selection(self.input.table.currentText())
self.input.table.set_current_text(table)
smpl = pos["ot_es1_trz"] + pos["es1man_trz"]
fm_focx, fm_focy = self.core.calc_beamsize(
h_acc, v_acc, fm_stripe, -fm_rotx_real * 1e-3, pos["fm_bnd_radius"] * 1e6, smpl
@@ -866,7 +887,9 @@ class DigitalTwin(BECWidget, QWidget):
self.mover.ot_try.set_target(out["ot_try"]["value"])
self.mover.ot_rotx.set_target(out["ot_rotx"]["value"])
self.mover.ot_es1_trz.set_target(out["ot_es1_trz"]["value"])
self.mover.ot_es2_trz.set_target(out["ot_es2_trz"]["value"])
self.mover.es0wi_try.set_target(out["es0wi_try"]["value"])
self.mover.es1man_trz.set_target(out["es1man_trz"]["value"])
if self.beamline == "x10da":
self.mover.es1_try.set_target(out["es1_try"]["value"])
self.mover.es1ic0_try.set_target(out["es1ic0_try"]["value"])
@@ -918,12 +941,14 @@ class DigitalTwin(BECWidget, QWidget):
Literal["Defocused", "Focused", "Manual"], self.input.fm_focus.currentText()
)
fm_stripe = self.input.fm_stripe.currentText()
match self.input.smpl:
case InputNumberField():
smpl = self.input.smpl.value()
case ComboBox():
table = self.input.smpl.currentText()
smpl = self.core.table_to_smpl_pos(table)
es1man_trz = self.input.es1man_trz.value()
if self.beamline == "x01da":
ot_es1_trz = self.input.ot_es1_trz.value()
else:
table = self.input.table.currentText()
ot_es1_trz = self.core.table_to_smpl_pos(table)
smpl = ot_es1_trz + es1man_trz
sldi_hacc = self.input.sldi_hacc.value() * 1e-3
sldi_vacc = self.input.sldi_vacc.value() * 1e-3
fm_focx = self.input.fm_focx.value()
@@ -173,7 +173,45 @@ class InputPanel(QWidget):
# Sample
self.cm_fm_harm_suppr = NumberIndicator("Total Suppression Factor at x eV", "", decimals=0)
self.smpl = self._create_smpl(beamline)
self.es1man_trz = InputNumberField(
"es1man_trz",
"Sample Manipulator TRZ",
unit="mm",
init=0,
decimals=1,
single_step=1,
ll=-60,
hl=60,
)
if beamline == BeamlineId.X01DA:
self.ot_es1_trz = InputNumberField(
"ot_es1_trz",
"ES1 Position",
unit="mm",
init=23511,
decimals=0,
single_step=100,
ll=23000,
hl=30000,
)
self.smpl_to_xrd = InputNumberField(
"smpl_to_xrd",
"Sample to XRD Det",
unit="mm",
init=320,
decimals=0,
single_step=10,
ll=320,
hl=6000,
)
self.smpl_group = Group(
"Sample Position", [self.ot_es1_trz, self.es1man_trz, self.smpl_to_xrd]
)
else:
self.table = ComboBox("table", "Sample Position", ["ES1", "ES2"])
self.smpl_group = Group("Sample Position", [self.ot_es1_trz, self.es1man_trz])
# Assemble complete assistant group
self.input_group = Group(
@@ -186,25 +224,9 @@ class InputPanel(QWidget):
self.mo1_ass_group,
self.fm_ass_group,
self.cm_fm_harm_suppr,
self.smpl,
self.smpl_group,
],
)
self._layout.addWidget(self.input_group)
self._layout.addStretch()
def _create_smpl(self, beamline: BeamlineId) -> Union[InputNumberField, ComboBox]:
match beamline:
case BeamlineId.X01DA:
return InputNumberField(
"smpl",
"Sample Position",
unit="mm",
init=23511,
decimals=0,
single_step=100,
ll=23000,
hl=30000,
)
case BeamlineId.X10DA:
return ComboBox("smpl", "Sample Position", ["ES1", "ES2"])
@@ -343,6 +343,16 @@ class MoverPanel(QWidget):
deadband=5,
)
self.mover_widgets.append(self.ot_es1_trz)
self.es1man_trz = MoveWidget(
beamline=beamline,
dev=dev,
motor="es1man_trz",
label="MANIP TRZ",
unit="mm",
decimals=2,
deadband=0.1,
)
self.mover_widgets.append(self.es1man_trz)
if beamline == "x10da":
self.es1_try = MoveWidget(
@@ -390,13 +400,25 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.es1ic2_try)
if beamline == "x01da":
self.es1_mov_group = Group("Experimental Station 1", [self.ot_es1_trz])
self.es1_mov_group = Group("Experimental Station 1", [self.ot_es1_trz, self.es1man_trz])
else:
self.es1_mov_group = Group(
"Experimental Station 1", [self.es1_try, self.es1ic1_try, self.es1ic2_try]
)
# Experimental Station 2
if beamline == "x01da":
self.ot_es2_trz = MoveWidget(
beamline=beamline,
dev=dev,
motor="ot_es2_trz",
label="ES2 TRZ",
unit="mm",
decimals=0,
deadband=5,
)
self.mover_widgets.append(self.ot_es2_trz)
if beamline == "x10da":
self.es2_try = MoveWidget(
beamline=beamline,
@@ -409,6 +431,9 @@ class MoverPanel(QWidget):
)
self.mover_widgets.append(self.es2_try)
if beamline == "x01da":
self.es2_mov_group = Group("Experimental Station 2", [self.ot_es2_trz])
else:
self.es2_mov_group = Group("Experimental Station 2", [self.es2_try])
# Assemble complete mover group
@@ -428,6 +453,7 @@ class MoverPanel(QWidget):
self.ot_mov_group,
self.es0_mov_group,
self.es1_mov_group,
self.es2_mov_group,
],
)
else:
@@ -1,7 +1,6 @@
"""Move widget to display an axis and also move it through BEC"""
import threading
import time
from typing import Literal, Optional
from bec_lib import bec_logger
@@ -11,15 +10,13 @@ from bec_widgets.utils.colors import get_accent_colors
# pylint: disable=E0611
from qtpy.QtCore import Property # type: ignore[attr-defined]
from qtpy.QtCore import Signal # type: ignore[attr-defined]
from qtpy.QtCore import QObject, QPropertyAnimation, Qt, QThread
from qtpy.QtCore import QObject, QPropertyAnimation, Qt
from qtpy.QtGui import QTransform
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QPushButton, QWidget
from .....bec_ipython_client.plugins.digital_twin_core.motion_worker import MotionWorker
from .....bec_ipython_client.plugins.digital_twin_core.types import BeamlineId
# pylint: disable=E0402
from .....devices.absorber import STATUS as ABS_STATUS
logger = bec_logger.logger
@@ -124,222 +121,13 @@ class StatusIcon(QWidget):
self._label.setPixmap(icon)
class MotionWorker(QObject):
"""
Executes motion on the specified motor and includes some safety during
motion for certain motors.
"""
class _WorkerSignals(QObject):
"""Tiny QObject just to own signals, since MotionWorker itself is no longer a QObject."""
position_changed = Signal(float)
error = Signal()
finished = Signal()
def __init__(self, beamline: BeamlineId, dev, motor, target_pos: float):
super().__init__()
self.beamline = beamline
self.dev = dev
self.motor = motor
self._target = target_pos
self._stop_flag = threading.Event()
def stop(self):
"""Sets the stop flag"""
self._stop_flag.set()
def run(self):
"""Prepares the movement based on the axis (motor)"""
match self.motor:
case "sldi_gapx" | "sldi_gapy" | "sldi_centerx" | "sldi_centery":
self.motion()
case "cm_trx":
self.motion(
abs_closed=True,
surveyed_axes=[{"device": self.dev["cm_roty"], "abs_tol": 0.05}],
)
case "cm_roty":
self.motion(
abs_closed=True, surveyed_axes=[{"device": self.dev["cm_trx"], "abs_tol": 0.05}]
)
case "cm_try":
self.motion(
abs_closed=True,
surveyed_axes=[
{"device": self.dev["cm_rotx"], "abs_tol": 0.05},
{"device": self.dev["cm_rotz"], "abs_tol": 0.05},
],
)
case "cm_rotx":
self.motion(
abs_closed=True,
surveyed_axes=[
{"device": self.dev["cm_try"], "abs_tol": 0.05},
{"device": self.dev["cm_rotz"], "abs_tol": 0.05},
],
)
case "cm_rotz":
self.motion(
abs_closed=True,
surveyed_axes=[
{"device": self.dev["cm_try"], "abs_tol": 0.05},
{"device": self.dev["cm_rotx"], "abs_tol": 0.05},
],
)
case "cm_bnd":
if self.beamline == "x01da":
p1 = (
1 / (self.dev.cm_bnd_radius.read()["cm_bnd_radius"]["value"] * 1e3) + 0.0284
) / 2e-6
p2 = (1 / (self._target * 1e3) + 0.0284) / 2e-6
else:
p1 = 541900 / self.dev.cm_bnd_radius.read()["cm_bnd_radius"]["value"] - 32570
p2 = 541900 / self._target - 32570
self._target = p2 - p1
self.motion(relative=True, rb={"device": self.dev["cm_bnd_radius"]})
case "mo1_try" | "mo1_trx" | "mo1_roty":
self.motion(abs_closed=True)
case "mo1_bragg_angle":
if self.beamline == "x01da":
self.motion()
else: # x10da needs to move goniometer
self.motion(alias="mo1_rotx")
case "sl1_centery" | "sl1_gapy" | "bm1_try":
self.motion()
case "fm_trx":
self.motion(
abs_closed=True,
surveyed_axes=[{"device": self.dev["fm_roty"], "abs_tol": 0.05}],
)
case "fm_roty":
self.motion(
abs_closed=True, surveyed_axes=[{"device": self.dev["fm_trx"], "abs_tol": 0.05}]
)
case "fm_try":
if self.beamline == "x01da":
abs_tol = 0.05
else: # superxas mirror less stable thus needs higher tolerance
abs_tol = 0.2
self.motion(
abs_closed=True,
surveyed_axes=[
{"device": self.dev["fm_rotx"], "abs_tol": abs_tol},
{"device": self.dev["fm_rotz"], "abs_tol": abs_tol},
],
)
case "fm_rotx":
if self.beamline == "x01da":
abs_tol = 0.05
else: # superxas mirror less stable thus needs higher tolerance
abs_tol = 0.2
self.motion(
abs_closed=True,
surveyed_axes=[
{"device": self.dev["fm_try"], "abs_tol": abs_tol},
{"device": self.dev["fm_rotz"], "abs_tol": abs_tol},
],
)
case "fm_rotz":
if self.beamline == "x01da":
abs_tol = 0.05
else: # superxas mirror less stable thus needs higher tolerance
abs_tol = 0.2
self.motion(
abs_closed=True,
surveyed_axes=[
{"device": self.dev["fm_try"], "abs_tol": abs_tol},
{"device": self.dev["fm_rotx"], "abs_tol": abs_tol},
],
)
case "fm_bnd":
if self.beamline == "x01da":
p1 = (
1 / (self.dev.fm_bnd_radius.read()["fm_bnd_radius"]["value"] * 1e3)
+ 4.28e-5
) / 1.84e-9
p2 = (1 / (self._target * 1e3) + 4.28e-5) / 1.84e-9
else:
p1 = (
593088.7 / self.dev.fm_bnd_radius.read()["fm_bnd_radius"]["value"]
+ 26124.41
)
p2 = 593088.7 / self._target + 26124.41
self._target = p2 - p1
self.motion(relative=True, rb={"device": self.dev["fm_bnd_radius"]})
case "sl2_centery" | "sl2_gapy" | "bm2_try":
self.motion()
case "ot_try" | "ot_rotx" | "ot_es1_trz":
self.motion()
case "es0wi_try":
self.motion()
case "es1_try" | "es2_try":
self.motion()
case "es1ic0_try" | "es1ic1_try" | "es1ic2_try":
self.motion()
case _:
logger.warning(f"Motor {self.motor} not integrated in digital twin!")
def motion(
self,
abs_closed: bool = False,
relative: bool = False,
rb=None,
surveyed_axes=None,
alias=None,
):
"""
Moves an axis while surverying a set of axes (if set).
Example surveyed_axes:
[{'device': bec_device_object, 'abs_tol': 0.1},]
Args:
surveyed_axes (list): List of dictionaries of devices
"""
logger.info(f"Move axis {self.motor} to target {self._target}, move_relative={relative}")
try:
if alias:
self.motor = alias
if abs_closed:
if self.dev.abs.status.get() == ABS_STATUS.OPEN:
status = self.dev.abs.close()
status.wait(timeout=5)
if surveyed_axes is not None:
for surv_ax in surveyed_axes:
surv_ax["name"] = surv_ax["device"].dotted_name
surv_ax["old_value"] = surv_ax["device"].read(cached=True)[surv_ax["name"]][
"value"
]
if rb is not None:
rb["name"] = rb["device"].dotted_name
status = self.dev[self.motor].move(self._target, relative=relative)
last_check = time.time()
update_interval = 0.1
while status.status == "RUNNING":
now = time.time()
if time.time() - last_check < update_interval:
time.sleep(0.01)
last_check = now
if self._stop_flag.is_set():
self.dev[self.motor].stop()
self._stop_flag.clear()
if rb is not None:
self.position_changed.emit(rb["device"].read(cached=True)[rb["name"]]["value"])
else:
self.position_changed.emit(
self.dev[self.motor].read(cached=True)[self.motor]["value"]
)
if surveyed_axes is not None:
for surv_ax in surveyed_axes:
fb = surv_ax["device"].read(cached=True)[surv_ax["name"]]["value"]
if abs(fb - surv_ax["old_value"]) > surv_ax["abs_tol"]:
self.dev[self.motor].stop()
self.error.emit()
self.finished.emit()
break
self.finished.emit()
except Exception as e:
logger.error(f"Error during movement of {self.motor}: {e}")
self.error.emit()
self.finished.emit()
class MoveWidget(QWidget):
"""
@@ -361,8 +149,9 @@ class MoveWidget(QWidget):
self.motor = motor
self.deadband = deadband
self.status = Status.IN_POSITION
self._thread: QThread | None = None
self._thread: threading.Thread | None = None
self._worker: MotionWorker | None = None
self._signals: _WorkerSignals | None = None
self.text_color = (0, 0, 0)
@@ -488,7 +277,7 @@ class MoveWidget(QWidget):
def _on_button_clicked(self):
"""Starts or stops motion depending on current situation"""
if self._thread and self._thread.isRunning():
if self._thread and self._thread.is_alive():
self._stop_motion()
else:
self._start_motion()
@@ -503,18 +292,22 @@ class MoveWidget(QWidget):
self._set_status(Status.MOVING)
self._apply_button_style("stop")
self._worker = MotionWorker(self.beamline, self.dev, self.motor, target)
self._thread = QThread()
self._worker.moveToThread(self._thread)
self._thread.started.connect(self._worker.run)
self._worker.position_changed.connect(self._on_position_changed)
self._worker.error.connect(self._on_error)
self._worker.error.connect(self._thread.quit)
self._worker.finished.connect(self._on_motion_finished)
self._worker.finished.connect(self._thread.quit)
self._thread.finished.connect(self._cleanup_thread)
self._signals = _WorkerSignals()
self._signals.position_changed.connect(self._on_position_changed)
self._signals.error.connect(self._on_error)
self._signals.finished.connect(self._on_motion_finished)
self._signals.finished.connect(self._cleanup_thread)
self._worker = MotionWorker(
self.beamline,
self.dev,
self.motor,
target,
on_position_changed=self._signals.position_changed.emit,
on_error=self._signals.error.emit,
on_finished=self._signals.finished.emit,
)
self._thread = threading.Thread(target=self._worker.run, daemon=True)
self._thread.start()
def _on_error(self):
@@ -547,20 +340,16 @@ class MoveWidget(QWidget):
def _cleanup_thread(self):
"""Cleaning up of the mover thread"""
if self._thread:
self._thread.deleteLater()
self._thread = None
if self._worker:
self._worker.deleteLater()
self._worker = None
self._thread = None
self._worker = None
self._signals = None
def shutdown(self):
"""Cleaning up of the mover when shutting down the application"""
if self._worker:
self._worker.stop()
if self._thread:
self._thread.quit()
self._thread.wait(2000) # max 2 s grace period
if self._thread and self._thread.is_alive():
self._thread.join(2.0) # max 2 s grace period
class AbsorberWidget(QWidget):