WIP: widget development #105

Draft
hitz_s wants to merge 50 commits from feat/widget-development into main
pull from: feat/widget-development
79 changed files with 24847 additions and 2257 deletions

No files matched your search

@@ -0,0 +1,396 @@
"""Module to automatically set the gains for the selected amplifiers"""
import builtins
import time
from bisect import bisect_right
import xraydb
from bec_lib import bec_logger
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 EpicsMode, NidaqState
from ...devices.op_shutter import STATUS as OP_PH_STATUS
from .beamline import BeamlineId, get_beamline_id
logger = bec_logger.logger
EMIN = -200 # Minimum energy will be edge energy - EMIN
EMAX = 1000 # Maximum energy will be edge energy + EMAX
MIN_RING_CURRENT = 1 # 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 (only X01DA)
TIMEOUT_MONO_MOVE = 30 # Timeout to finish a movement on the mono
AVAILABLE_GAINS_X01DA = [1e6, 1e7, 5e7, 1e8, 1e9] # ascending order
AVAILABLE_GAINS_X10DA = [1e6, 1e7, 1e8, 1e9, 1e10] # ascending order
MIN_SIGNAL = 0.05 # Minimum signal to count as valid signal
FULL_SCALE_V = 10.0 # NIDAQ AI full-scale range
SAFETY_MARGIN = 0.9 # keep max signal under 90% of full scale
def wait_for_signal(signal, value, timeout: float) -> None:
"""Function to wait for a signal to reach a certain value.
Args:
signal: Signal to read from
value: Target value the signal should reach
timeout(float): Timeout in seconds before a TimeoutError is raised
"""
start = time.time()
while time.time() - start < timeout:
if signal.get() == value:
return
raise TimeoutError(f"Signal {signal} did not reach value {value} within {timeout} seconds.")
class AutoGainError(Exception):
"""AutoGain specific error"""
class AutoGainCore:
"""Module to automatically set the gains for the selected amplifiers"""
def __init__(self):
dev = builtins.__dict__.get("dev")
bec = builtins.__dict__.get("bec")
if dev is None:
raise AutoGainError("Did not get dev")
if bec is None:
raise AutoGainError("Did not get bec")
self.dev = dev
self.bec = bec
self.beamline = get_beamline_id()
if self.beamline == BeamlineId.X01DA:
self.available_gains = AVAILABLE_GAINS_X01DA
else:
self.available_gains = AVAILABLE_GAINS_X10DA
self.linked_channels = None
self.prohibited_gains = {}
def allowed_gains(self, amplifier):
"""Returns a list of gains which are allowed for the amplifier"""
if amplifier in self.prohibited_gains:
gains = self.available_gains.copy()
for prohibited_gain in self.prohibited_gains[amplifier]:
gains.remove(prohibited_gain)
return gains
return self.available_gains
def start(
self,
element: str,
edge: str,
amplifier: list[str] | None = None,
link_ic0_ic1: bool = True,
comp_ring_current: bool = True,
prohibited_gains: dict | None = None,
) -> dict:
"""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'
amplifier(list[str]): Amplifiers where auto-gain should be applied to
Defaults to all amplifiers ->
X01DA = ['ic0', 'ic1', 'ic2', 'pips']
X10DA = ['ic0', 'ic1', 'ic2']
link_ic0_ic1(bool): Apply same gain to ic0 and ic1. Defaults to True
comp_ring_current(bool): Respects the current ring current and calculates the gain(s)
for a nominal ring current of 400 mA. Defaults to True
prohibited_gains(dict): Gains which are not allowed to be used.
E.g.: {'ic0': [1e6]}. Defaults to None
Raises:
If NIDAQ is not in measurement mode
If Ring current is below 5 mA
If Absorber, OP Photon Shutter or EH Photon Shutter is closed
If a bec scan is running
If the energy for the supplied element/edge cannot be found
If the energy is outside the movement range of the monochromator
If a selected amplifier is switched off
If the high voltage of a selected ionization chamber is not enabled or < 1000 V
If the gas filling of a selected ionization chamber is not OK
If the ring current drops to 0 mA during the measurement (beamdump)
"""
if link_ic0_ic1:
self.linked_channels = {"ic0", "ic1"}
else:
self.linked_channels = {}
if prohibited_gains is None:
self.prohibited_gains = {}
else:
self.prohibited_gains = prohibited_gains
if amplifier is None:
if self.beamline == BeamlineId.X01DA:
amplifier = ["ic0", "ic1", "ic2", "pips"]
else:
amplifier = ["ic0", "ic1", "ic2"]
# Make sure NIDAQ is in standby mode
if self.dev.nidaq.state.get() != NidaqState.STANDBY:
raise AutoGainError("NIDAQ was not in Standby mode, cannot proceed.")
# Check for beam availability
if self._get_ring_current() < MIN_RING_CURRENT:
raise AutoGainError(f"Ring current is below {MIN_RING_CURRENT} mA")
if self.dev.abs.status.get() != ABS_STATUS.OPEN:
raise AutoGainError("Absorber is closed, no beam")
if self.dev.op_sh.status.get() != OP_PH_STATUS.NOT_CLOSED:
raise AutoGainError("OP Photon Shutter is closed, no beam")
if self.dev.eh_sh.status.get() != EH_PH_STATUS.NOT_CLOSED:
raise AutoGainError("EH Photon Shutter is closed, no beam")
# Check if no scan is running
scan_id = self.bec.queue.scan_storage.current_scan_id
if len(scan_id) > 0:
raise AutoGainError(f"Scan with ID {scan_id} is currently running, cannot continue")
# Get edge energy
energy = xraydb.xray_edge(element, edge, True)
if energy is None:
raise ValueError(f"Could not find edge energy for element/edge {element}/{edge}")
emin = energy + EMIN
emax = energy + EMAX
# Check range of mono (only X01DA)
if self.beamline == BeamlineId.X01DA:
low_limit = self.dev.mo1_bragg.low_lim.get()
high_limit = self.dev.mo1_bragg.high_lim.get()
if emin < low_limit or emax > high_limit:
raise ValueError(
f"Chosen element/edge {element}/{edge} with edge energy of {energy}"
+ " is outside of accessible range of monochromator "
+ f"{low_limit:.1f} eV - {high_limit:.1f} eV"
)
# X10DA energy axis (goniometer) does not have lo/hi limits
# Map amplifier names to their NIDAQ channels
channel_map = {
"ic0": {"signal": self.dev.nidaq.ai0, "self.dev": self.dev.ic0},
"ic1": {"signal": self.dev.nidaq.ai2, "self.dev": self.dev.ic1},
"ic2": {"signal": self.dev.nidaq.ai4, "self.dev": self.dev.ic2},
"pips": {"signal": self.dev.nidaq.ai6, "self.dev": self.dev.pips},
}
active_channels = {name: ch for name, ch in channel_map.items() if name in amplifier}
# Tests only for X01DA:
if self.beamline == BeamlineId.X01DA:
# Check if amplifieres are switched on (only X01DA)
for name, ch in active_channels.items():
if ch["self.dev"].amp.cOnOff.get() != AmplifierEnable.ON:
raise AutoGainError(f"Amplifier of self.device {name} is not enabled")
# Check high voltage on ionization chambers (only X01DA)
for name, ch in active_channels.items():
if name != "pips":
if ch["self.dev"].hv_en.ena.get() != 1:
raise AutoGainError(
f"High voltage of ionization chamber {name} is not enabled"
)
if ch["self.dev"].hv.hv_v.get() < 1000:
raise AutoGainError(f"HV voltage of ionization chamber {name} is < 1000")
if ch["self.dev"].hv.grid_v.get() < 1000:
raise AutoGainError(f"Grid voltage of ionization chamber {name} is < 1000")
# Check gas filling of ionization chambers (only X01DA)
for name, ch in active_channels.items():
if name != "pips":
if ch["self.dev"].gmes.status.get() != 1:
raise AutoGainError(f"Gas filling of ionization chamber {name} is not OK")
# Get initial monochromator position and velocity
if self.beamline == BeamlineId.X01DA:
init_pos = self.dev.mo1_bragg.position
init_vel = self.dev.mo1_bragg.velocity.get()
else:
init_pos = self.dev.mo1_rotx_energy.get()
init_vel = None
# Get initial gains
for name, ch in active_channels.items():
ch["init_gain"] = ch["self.dev"].amp.cGain_ENUM.get()
logger.info("All checks done, start preparing for measurement")
results = {}
try:
logger.info(f"Move mono to start of {emin} eV")
if self.beamline == BeamlineId.X01DA:
self.dev.mo1_bragg.velocity.put(MONO_VELOCITY)
wait_for_signal(self.dev.mo1_bragg.velocity, MONO_VELOCITY, timeout=TIMEOUT_PUT_PV)
status = self.dev.mo1_bragg.move(emin)
else:
status = self.dev.mo1_rotx_energy.move(emin)
status.wait(TIMEOUT_MONO_MOVE)
# Set NIDAQ to max mode
self.dev.nidaq.epics_mode.put(EpicsMode.MAX)
wait_for_signal(self.dev.nidaq.epics_mode, EpicsMode.MAX, timeout=TIMEOUT_PUT_PV)
# Set gains to lowest gain
for name, ch in active_channels.items():
lowest_gain = self.allowed_gains(name)[0]
ch["self.dev"].set_gain(lowest_gain)
ch["gain"] = lowest_gain
time.sleep(0.5)
remeasure = True
logger.info(f"Start measurement from {emin} eV to {emax} eV")
while remeasure:
# Rest max values of NIDAQ signals
self.dev.nidaq.epics_max_reset.put(True)
time.sleep(0.2)
# Create temporary storage for max signal per channel
data = {name: 0 for name in active_channels}
# Measure current ring current
ring_current_1 = self._get_ring_current()
logger.info(f"Ring current right before measurement: {ring_current_1} mA")
if ring_current_1 < MIN_RING_CURRENT:
raise AutoGainError(
f"Ring current dropped below {MIN_RING_CURRENT} mA right before measurement"
)
# Scan range, recording the peak NIDAQ signal per channel
if self.beamline == BeamlineId.X01DA:
self.dev.mo1_bragg.move(emax).wait(timeout=TIMEOUT_MONO_MOVE)
else:
self.dev.mo1_rotx_energy.move(emax).wait(timeout=TIMEOUT_MONO_MOVE)
for name, ch in active_channels.items():
data[name] = max(data[name], ch["signal"].get())
# Measure current ring current again
ring_current_2 = self._get_ring_current()
logger.info(f"Ring current right after measurement: {ring_current_2} mA")
if ring_current_2 < MIN_RING_CURRENT:
raise AutoGainError(
f"Ring current dropped below {MIN_RING_CURRENT} mA during measurement"
)
ring_current = (ring_current_1 + ring_current_2) / 2
# Move back to first monochromator position
if self.beamline == BeamlineId.X01DA:
status = self.dev.mo1_bragg.move(emin)
else:
status = self.dev.mo1_rotx_energy.move(emin)
# Choose gain per channel based on the max signal recorded during the scan
remeasure = False
processed = set()
for name, ch in active_channels.items():
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 self.linked_channels:
group_names = [n for n in self.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 == self.allowed_gains(name)[-1]:
logger.warning(
f"Amplifier of {label} at highest gain {gain_ref} and still not"
+ f" measured signal above {MIN_SIGNAL}"
)
else:
next_gain = self.allowed_gains(name)[
bisect_right(self.allowed_gains(name), gain_ref * 100) - 1
]
for gname in group_names:
active_channels[gname]["self.dev"].set_gain(next_gain)
active_channels[gname]["gain"] = next_gain
time.sleep(0.5)
logger.info(f"Setting gain of {label} to {next_gain:.0e} and remeasure")
remeasure = True
else:
gain = max(
(
g
for g in self.allowed_gains(name)
if raw_signal_max / gain_ref * g <= FULL_SCALE_V * SAFETY_MARGIN
),
default=min(self.allowed_gains(name)),
)
for gname in group_names:
active_channels[gname]["self.dev"].set_gain(gain)
active_channels[gname]["gain"] = gain
logger.info(f"Calculated final gain for {label} of {gain:.0e}")
results[label] = {
"gain": gain,
"max_signal": raw_signal_max / gain_ref * gain,
}
processed.update(group_names)
# Wait for mono to return to start position
status.wait(TIMEOUT_MONO_MOVE)
except Exception as e:
logger.error("Error during measurement, reset gains to their initial values")
for name, ch in active_channels.items():
ch["self.dev"].amp.cGain_ENUM.put(ch["init_gain"])
raise AutoGainError(f"During execution of auto-gain, an exception occured: {e}") from e
# Reset NIDAQ to mean mode
self.dev.nidaq.epics_mode.put(EpicsMode.MEAN)
wait_for_signal(self.dev.nidaq.epics_mode, EpicsMode.MEAN, timeout=TIMEOUT_PUT_PV)
# Wait for mono to move to initial position and reset velocity
if self.beamline == BeamlineId.X01DA:
status = self.dev.mo1_bragg.move(init_pos)
status.wait(TIMEOUT_MONO_MOVE)
self.dev.mo1_bragg.velocity.put(init_vel)
wait_for_signal(self.dev.mo1_bragg.velocity, init_vel, timeout=TIMEOUT_PUT_PV)
else:
status = self.dev.mo1_rotx_energy.move(init_pos)
status.wait(TIMEOUT_MONO_MOVE)
return results
def _get_ring_current(self) -> float:
ring_current = 0
retries = 0
while ring_current == 0 and retries < 10:
ring_current = self.dev.curr.get()
retries += 1
time.sleep(0.01)
return ring_current
@@ -1,12 +1,20 @@
import socket
from enum import Enum
from bec_lib import bec_logger
from .types import BeamlineId
logger = bec_logger.logger
class BeamlineId(str, Enum):
"""
Identifier for supported beamlines.
"""
X01DA = "x01da"
X10DA = "x10da"
def get_beamline_id() -> BeamlineId:
"""
Based on the bec servers hostname, tries to extract the beamline
@@ -38,14 +46,3 @@ def get_beamline_id() -> BeamlineId:
raise ValueError(f"Wrong selection {bl}")
else:
raise ValueError("Cannot open digital twin without a beamline")
def get_parameters():
beamline = get_beamline_id()
if beamline == "x01da":
from . import x01da_parameters as parameters
elif beamline == "x10da":
from . import x10da_parameters as parameters
else:
raise ValueError(f"Unknown beamline: {beamline}")
return parameters
@@ -0,0 +1,3 @@
from .digital_twin_core import get_parameters
parameters = get_parameters()
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,277 @@
"""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 ..beamline import BeamlineId
logger = bec_logger.logger
class MotionOutcome:
DONE = "done"
FAILED = "failed"
CANCELLED = "cancelled" # stopped by the user, or aborted because another motor failed
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(wait=True)
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)
continue
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=False)[rb["name"]]["value"]
)
else:
self._emit_position_changed(
self.dev[self.motor].read(cached=False)[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()
@@ -27,12 +27,15 @@ class ConfigDict(TypedDict):
mo1_mode (str): MO1 mode.
mo1_xtal (str): MO1 crystal.
mo1_bragg (float): MO1 Bragg angle.
mo1_roty (float): MO1 Azimuthal angle.
fm_rotx (float): FM rotation x.
fm_stripe (str): FM stripe name.
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
@@ -44,12 +47,15 @@ class ConfigDict(TypedDict):
mo1_mode: str
mo1_xtal: str
mo1_bragg: float
mo1_roty: float
fm_rotx: float
fm_stripe: str
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):
@@ -13,6 +13,12 @@ mo1_try:
range: [[-30, -0.1], [0.1, 30]]
offset: [-1.78, -1.78]
mo1_roty:
modifier:
axis: mo1_trx
range: [[-30, -0.1], [0.1, 30]] # Si311, Si111
offset: [-136.80017, -89.15503]
sl1_centery:
offset: -1.2
@@ -320,4 +320,4 @@ vacuum_pipes = pipe(
)
Walls = namedtuple("walls", ["start", "end", "height"])
walls = Walls(start=[13999.30], end=[13999 + 75.5 + 30], height=[[-20, 25]])
walls = Walls(start=[13999.30], end=[13999 + 75.5 + 30], height=[[-20, 25]]) # WBBS
@@ -11,6 +11,12 @@ mo1_trx:
range: [[-30, -0.1], [0.1, 30]]
offset: [-4.3, 0]
mo1_roty:
modifier:
axis: mo1_trx
range: [[-30, -0.1], [0.1, 30]] # Si111, Si311
offset: [-116.400, -137.605]
sl1_centery:
offset: -55.54
@@ -1,296 +1,299 @@
"""
X10DA / SuperXAS Beamline Parameters.
This file describes the parameter of each component of the SuperXAS beamline
to be used for raytracing and geometrical calculations.
"""
from collections import namedtuple
import numpy as np
import xrt.backends.raycing.materials as rm
# XRT definitions
filterBeryl = rm.Material("Be", rho=1.85, kind="plate") # pyright: ignore[reportArgumentType]
filterDiamond = rm.Material("C", rho=3.52, kind="plate") # pyright: ignore[reportArgumentType]
filterGraphite = rm.Material("C", rho=2.266, kind="plate") # pyright: ignore[reportArgumentType]
stripeSi = rm.Material("Si", rho=2.33) # pyright: ignore[reportArgumentType]
stripePt = rm.Material("Pt", rho=21.45) # pyright: ignore[reportArgumentType]
stripeRh = rm.Material("Rh", rho=12.41) # pyright: ignore[reportArgumentType]
stripeCr = rm.Material("Cr", rho=7.14) # pyright: ignore[reportArgumentType]
stripePyrex = rm.Material(
"Si", rho=2.20
) # Use Si as bare element and the density of SiO2 # pyright: ignore[reportArgumentType]
si111_1 = rm.CrystalSi(hkl=(1, 1, 1), tK=77) # first xtal surface
si311_1 = rm.CrystalSi(hkl=(3, 1, 1), tK=77) # first xtal surface
si333_1 = rm.CrystalSi(hkl=(3, 3, 3), tK=77) # first xtal surface
si511_1 = rm.CrystalSi(hkl=(5, 1, 1), tK=77) # first xtal surface
si111_2 = rm.CrystalSi(hkl=(1, 1, 1), tK=77) # second xtal surface
si311_2 = rm.CrystalSi(hkl=(3, 1, 1), tK=77) # second xtal surface
si333_2 = rm.CrystalSi(hkl=(3, 3, 3), tK=77) # second xtal surface
si511_2 = rm.CrystalSi(hkl=(5, 1, 1), tK=77) # second xtal surface
filterDiamond = rm.Material("C", rho=3.52, kind="plate") # pyright: ignore[reportArgumentType]
filterBe = rm.Material("Be", rho=1.85, kind="plate") # pyright: ignore[reportArgumentType]
filterSi3N4 = rm.Material(
["Si", "N"], quantities=[3, 4], rho=3.44, kind="plate"
) # pyright: ignore[reportArgumentType]
filterAl = rm.Material("Al", rho=2.69, kind="plate") # pyright: ignore[reportArgumentType]
filterGraphite = rm.Material("C", rho=2.266, kind="plate") # pyright: ignore[reportArgumentType]
# General parameters
sourceHeight = 0
# Synchrotron
synchrotron = namedtuple(
"synchrotron", ["eE", "eI", "eEspread", "eEpsilonX", "eEpsilonZ", "betaX", "betaZ"]
)
sls1 = synchrotron(
eE=2.4, eI=0.4, eEspread=0.878e-3, eEpsilonX=5.63, eEpsilonZ=0.007, betaX=0.45, betaZ=14.4
)
sls2 = synchrotron(
eE=2.7, eI=0.4, eEspread=1.147e-3, eEpsilonX=0.156, eEpsilonZ=0.01, betaX=0.18, betaZ=4.6
)
# Source
bendingMagnet = namedtuple("bendingMagnet", ["name", "center", "sync", "B0"])
sls1_14t = bendingMagnet(name="FE-BM-SLS1-1.4T", center=(0, 0, 0), sync=sls1, B0=1.4)
sls2_21t = bendingMagnet(name="FE-BM-SLS2-2.1T", center=(0, 0, 0), sync=sls2, B0=2.1)
sls2_35t = bendingMagnet(name="FE-BM-SLS2-3.5T", center=(0, 0, 0), sync=sls2, B0=3.5)
sls2_50t = bendingMagnet(name="FE-BM-SLS2-5.0T", center=(0, 0, 0), sync=sls2, B0=5.0)
# FE slits
fe_slits = namedtuple("slits", ["name", "center", "center1", "center2", "maxDivH", "maxDivV"])
feSlits = fe_slits(
name="FE-SLITS",
center=(0, 6117, sourceHeight),
center1=(0, 5038.4, sourceHeight),
center2=(0, 5282.9, sourceHeight),
maxDivH=1.8e-3,
maxDivV=0.8e-3,
)
# Filters
filt = namedtuple(
"filt", ["name", "center", "pitch", "limPhysX", "limPhysY", "surface", "material", "thickness"]
)
feWindow = filt(
name="FE-WINDOW",
center=(0.0, 6158, sourceHeight),
pitch=np.pi / 2,
limPhysX=(-6, 6),
limPhysY=(-3.0, 3.0),
surface="None",
material=filterDiamond,
thickness=0.1,
)
feWindow = feWindow._replace(
surface="CVD Diamond window {0:0.0f} $\\mu$m".format(feWindow.thickness * 1e3)
)
feFilt = filt(
name="FE-FI",
center=(0.0, 6590, sourceHeight),
pitch=np.pi / 2,
limPhysX=(-15, 15),
limPhysY=(-10, 10),
surface="None",
material=filterGraphite,
thickness=0.25,
)
feFilt = feFilt._replace(surface="Graphite filter {0:0.0f} $\\mu$m".format(feFilt.thickness * 1e3))
# Collimating mirror
collimatingMirror = namedtuple(
"collimatingMirror",
[
"name",
"center",
"surface",
"material",
"limPhysX",
"limPhysY",
"limOptX",
"limOptY",
"R",
"pitch",
"jack1",
"jack2",
"jack3",
"tx1",
"tx2",
],
)
cm = collimatingMirror(
name="FE-CM",
center=[0, 7560.8, sourceHeight],
surface=("Pt", "Si", "Rh"),
material=(stripePt, stripeSi, stripeRh),
limPhysX=(-30, 30),
limPhysY=(-600, 600),
limOptX=((-21, -0.5, 11), (-4, 9.5, 23)),
limOptY=((-500, -500, -500), (500, 500, 500)),
R=[3e6, 15e6],
pitch=[1.4e-3, 4.5e-3],
jack1=[0.0, 7210.0, 0.0], # Tripod X, Y, Z (global)
jack2=[-210.0, 8310.0, 0.0],
jack3=[210.0, 8310.0, 0.0],
tx1=[0.0, -575.5], # X-Stage 1 [x, y] (local)
tx2=[0.0, 575],
) # X-Stage 2
apertures = namedtuple("apertures", ["name", "center", "opening"])
fePS = apertures(
name="FE-PS", center=[0, 8760, sourceHeight], opening=[-39 / 2, 39 / 2, -10, 29]
) # left, right, bottom, top
opWbBsBlock = apertures(
name="OP-WB-BS-BLOCK", center=[0.0, 13606 - 135, sourceHeight], opening=[-18.0, 18.0, 42, 76]
) # left, right, bottom, top
opSlits1 = apertures(
name="OP-SLITS 1", center=[0, 14145 - 135, sourceHeight], opening=[-35 / 2, 35 / 2, 47.5, 82.5]
)
# OP Beam Monitors
op_bm = namedtuple("op_bm", ["name", "center"])
opBM1 = op_bm(name="OP Beam Monitor 1", center=(0, 14525 - 135, sourceHeight))
opBM2 = op_bm(name="OP Beam Monitor 2", center=(0, 17161.6 - 135, sourceHeight))
# Monochromator
monochromator = namedtuple(
"monochromator",
[
"name",
"center",
"xtal",
"material1",
"material2",
"xtalWidth",
"xtalOffsetX",
"xtalLength1",
"xtalLength2",
"xtalGap",
"rotOffset",
"heightOffset",
"braggLim",
"jack1",
"jack2",
"jack3",
"tx",
],
)
mo1 = monochromator(
name="OP-CCM1",
center=[0.0, 11670 - 135, sourceHeight],
xtal=("Si311", "Si111"),
material1=(si311_1, si111_1),
material2=(si311_2, si111_2),
xtalWidth=(20, 20),
xtalOffsetX=(19.2, -19.2),
xtalLength1=(60, 60),
xtalLength2=(60, 60),
xtalGap=(8, 8),
rotOffset=6, # not sure what it is
heightOffset=8.5, # not sure what it is
braggLim=[4, 35],
jack1=[0.0, 11350.0, 0.0], # Tripod not available!
jack2=[-400.0, 12350.0, 0.0],
jack3=[400.0, 12350.0, 0.0],
tx=0.0,
) # X-Stage [x]
# Focusing mirror
focusingMirror = namedtuple(
"focusingMirror",
[
"name",
"center",
"surfaceToroid",
"materialToroid",
"limPhysXToroid",
"limPhysYToroid",
"limOptXToroid",
"limOptYToroid",
"R",
"pitch",
"r",
"xToroid",
"hToroid",
"jack1",
"jack2",
"jack3",
"tx1",
"tx2",
],
)
OFFSET_TRX = 46.8735
fm = focusingMirror(
name="OP-FM",
center=[0.0, 15580 - 135, sourceHeight],
surfaceToroid=("Rh", "Pt"),
materialToroid=(stripeRh, stripePt),
limPhysXToroid=(-54.0, 54.0),
limPhysYToroid=(-565.0, 565.0),
limOptXToroid=(
(43.388 + OFFSET_TRX, -4.865 + OFFSET_TRX),
(4.865 + OFFSET_TRX, -40.882 + OFFSET_TRX),
),
limOptYToroid=((-500.0, -500.0), (500.0, 500.0)),
R=[3e6, 15e6],
pitch=[1.4e-3, 4.5e-3],
r=[30, 20],
xToroid=[24.126 + OFFSET_TRX, -22 + OFFSET_TRX], # offset in local x
hToroid=[7.0, 11.3], # depth of the cylinder at x = xCylinder1 and x = xCylinder2.
jack1=[0.0, 14980.0, 0.0],
jack2=[-75.0, 16180.0, 0.0],
jack3=[75.0, 16180.0, 0.0],
tx1=[0.0, -575.0], # X-Stage 1 [x, y]
tx2=[0.0, 575.0],
) # X-Stage 2 [x, y]
# Entry wall experimental hutch: 21593 mm from source (SLS2)
# Exit window
ehWindow = filt(
name="EH-WINDOW",
center=(0.0, 22063, sourceHeight),
pitch=np.pi / 2,
limPhysX=(-10.0, 10.0),
limPhysY=(17.5, 92.5),
surface="None",
material=filterBe,
thickness=0.25,
)
ehWindow = ehWindow._replace(
surface="Beryllium window {0:0.0f} $\\mu$m".format(ehWindow.thickness * 1e3)
)
# Sample
sample = namedtuple("sample", ["name", "center"])
es1 = sample(name="ES1", center=[0, 23823, sourceHeight])
es2 = sample(name="ES2", center=[0, 25843, sourceHeight])
# Ionization chambers
ic = namedtuple("sample", ["name", "center"])
es1ic0 = ic(name="ES1 IC0", center=[0, 23633, sourceHeight])
es1ic1 = ic(name="ES1 IC1", center=[0, 24383, sourceHeight])
es1ic2 = ic(name="ES1 IC2", center=[0, 24723, sourceHeight])
"""
X10DA / SuperXAS Beamline Parameters.
This file describes the parameter of each component of the SuperXAS beamline
to be used for raytracing and geometrical calculations.
"""
from collections import namedtuple
import numpy as np
import xrt.backends.raycing.materials as rm
# XRT definitions
filterBeryl = rm.Material("Be", rho=1.85, kind="plate") # pyright: ignore[reportArgumentType]
filterDiamond = rm.Material("C", rho=3.52, kind="plate") # pyright: ignore[reportArgumentType]
filterGraphite = rm.Material("C", rho=2.266, kind="plate") # pyright: ignore[reportArgumentType]
stripeSi = rm.Material("Si", rho=2.33) # pyright: ignore[reportArgumentType]
stripePt = rm.Material("Pt", rho=21.45) # pyright: ignore[reportArgumentType]
stripeRh = rm.Material("Rh", rho=12.41) # pyright: ignore[reportArgumentType]
stripeCr = rm.Material("Cr", rho=7.14) # pyright: ignore[reportArgumentType]
stripePyrex = rm.Material(
"Si", rho=2.20
) # Use Si as bare element and the density of SiO2 # pyright: ignore[reportArgumentType]
si111_1 = rm.CrystalSi(hkl=(1, 1, 1), tK=77) # first xtal surface
si311_1 = rm.CrystalSi(hkl=(3, 1, 1), tK=77) # first xtal surface
si333_1 = rm.CrystalSi(hkl=(3, 3, 3), tK=77) # first xtal surface
si511_1 = rm.CrystalSi(hkl=(5, 1, 1), tK=77) # first xtal surface
si111_2 = rm.CrystalSi(hkl=(1, 1, 1), tK=77) # second xtal surface
si311_2 = rm.CrystalSi(hkl=(3, 1, 1), tK=77) # second xtal surface
si333_2 = rm.CrystalSi(hkl=(3, 3, 3), tK=77) # second xtal surface
si511_2 = rm.CrystalSi(hkl=(5, 1, 1), tK=77) # second xtal surface
filterDiamond = rm.Material("C", rho=3.52, kind="plate") # pyright: ignore[reportArgumentType]
filterBe = rm.Material("Be", rho=1.85, kind="plate") # pyright: ignore[reportArgumentType]
filterSi3N4 = rm.Material(
["Si", "N"], quantities=[3, 4], rho=3.44, kind="plate"
) # pyright: ignore[reportArgumentType]
filterAl = rm.Material("Al", rho=2.69, kind="plate") # pyright: ignore[reportArgumentType]
filterGraphite = rm.Material("C", rho=2.266, kind="plate") # pyright: ignore[reportArgumentType]
# General parameters
sourceHeight = 0
# Synchrotron
synchrotron = namedtuple(
"synchrotron", ["eE", "eI", "eEspread", "eEpsilonX", "eEpsilonZ", "betaX", "betaZ"]
)
sls1 = synchrotron(
eE=2.4, eI=0.4, eEspread=0.878e-3, eEpsilonX=5.63, eEpsilonZ=0.007, betaX=0.45, betaZ=14.4
)
sls2 = synchrotron(
eE=2.7, eI=0.4, eEspread=1.147e-3, eEpsilonX=0.156, eEpsilonZ=0.01, betaX=0.18, betaZ=4.6
)
# Source
bendingMagnet = namedtuple("bendingMagnet", ["name", "center", "sync", "B0"])
sls1_14t = bendingMagnet(name="FE-BM-SLS1-1.4T", center=(0, 0, 0), sync=sls1, B0=1.4)
sls2_21t = bendingMagnet(name="FE-BM-SLS2-2.1T", center=(0, 0, 0), sync=sls2, B0=2.1)
sls2_35t = bendingMagnet(name="FE-BM-SLS2-3.5T", center=(0, 0, 0), sync=sls2, B0=3.5)
sls2_50t = bendingMagnet(name="FE-BM-SLS2-5.0T", center=(0, 0, 0), sync=sls2, B0=5.0)
# FE slits
fe_slits = namedtuple("slits", ["name", "center", "center1", "center2", "maxDivH", "maxDivV"])
feSlits = fe_slits(
name="FE-SLITS",
center=(0, 6117, sourceHeight),
center1=(0, 5038.4, sourceHeight),
center2=(0, 5282.9, sourceHeight),
maxDivH=1.8e-3,
maxDivV=0.8e-3,
)
# Filters
filt = namedtuple(
"filt", ["name", "center", "pitch", "limPhysX", "limPhysY", "surface", "material", "thickness"]
)
feWindow = filt(
name="FE-WINDOW",
center=(0.0, 6158, sourceHeight),
pitch=np.pi / 2,
limPhysX=(-6, 6),
limPhysY=(-3.0, 3.0),
surface="None",
material=filterDiamond,
thickness=0.1,
)
feWindow = feWindow._replace(
surface="CVD Diamond window {0:0.0f} $\\mu$m".format(feWindow.thickness * 1e3)
)
feFilt = filt(
name="FE-FI",
center=(0.0, 6590, sourceHeight),
pitch=np.pi / 2,
limPhysX=(-15, 15),
limPhysY=(-10, 10),
surface="None",
material=filterGraphite,
thickness=0.25,
)
feFilt = feFilt._replace(surface="Graphite filter {0:0.0f} $\\mu$m".format(feFilt.thickness * 1e3))
# Collimating mirror
collimatingMirror = namedtuple(
"collimatingMirror",
[
"name",
"center",
"surface",
"material",
"limPhysX",
"limPhysY",
"limOptX",
"limOptY",
"R",
"pitch",
"jack1",
"jack2",
"jack3",
"tx1",
"tx2",
],
)
cm = collimatingMirror(
name="FE-CM",
center=[0, 7560.8, sourceHeight],
surface=("Pt", "Si", "Rh"),
material=(stripePt, stripeSi, stripeRh),
limPhysX=(-30, 30),
limPhysY=(-600, 600),
limOptX=((-21, -0.5, 11), (-4, 9.5, 23)),
limOptY=((-500, -500, -500), (500, 500, 500)),
R=[3e6, 15e6],
pitch=[1.4e-3, 4.5e-3],
jack1=[0.0, 7210.0, 0.0], # Tripod X, Y, Z (global)
jack2=[-210.0, 8310.0, 0.0],
jack3=[210.0, 8310.0, 0.0],
tx1=[0.0, -575.5], # X-Stage 1 [x, y] (local)
tx2=[0.0, 575],
) # X-Stage 2
apertures = namedtuple("apertures", ["name", "center", "opening"])
fePS = apertures(
name="FE-PS", center=[0, 8760, sourceHeight], opening=[-39 / 2, 39 / 2, -10, 29]
) # left, right, bottom, top
opWbBsBlock = apertures(
name="OP-WB-BS-BLOCK", center=[0.0, 13606 - 135, sourceHeight], opening=[-18.0, 18.0, 42, 76]
) # left, right, bottom, top
opSlits1 = apertures(
name="OP-SLITS 1", center=[0, 14145 - 135, sourceHeight], opening=[-35 / 2, 35 / 2, 47.5, 82.5]
)
# OP Beam Monitors
op_bm = namedtuple("op_bm", ["name", "center"])
opBM1 = op_bm(name="OP Beam Monitor 1", center=(0, 14525 - 135, sourceHeight))
opBM2 = op_bm(name="OP Beam Monitor 2", center=(0, 17161.6 - 135, sourceHeight))
# Monochromator
monochromator = namedtuple(
"monochromator",
[
"name",
"center",
"xtal",
"material1",
"material2",
"xtalWidth",
"xtalOffsetX",
"xtalLength1",
"xtalLength2",
"xtalGap",
"rotOffset",
"heightOffset",
"braggLim",
"jack1",
"jack2",
"jack3",
"tx",
],
)
mo1 = monochromator(
name="OP-CCM1",
center=[0.0, 11670 - 135, sourceHeight],
xtal=("Si311", "Si111"),
material1=(si311_1, si111_1),
material2=(si311_2, si111_2),
xtalWidth=(20, 20),
xtalOffsetX=(19.2, -19.2),
xtalLength1=(60, 60),
xtalLength2=(60, 60),
xtalGap=(8, 8),
rotOffset=6, # not sure what it is
heightOffset=8.5, # not sure what it is
braggLim=[4, 35],
jack1=[0.0, 11350.0, 0.0], # Tripod not available!
jack2=[-400.0, 12350.0, 0.0],
jack3=[400.0, 12350.0, 0.0],
tx=0.0,
) # X-Stage [x]
# Focusing mirror
focusingMirror = namedtuple(
"focusingMirror",
[
"name",
"center",
"surfaceToroid",
"materialToroid",
"limPhysXToroid",
"limPhysYToroid",
"limOptXToroid",
"limOptYToroid",
"R",
"pitch",
"r",
"xToroid",
"hToroid",
"jack1",
"jack2",
"jack3",
"tx1",
"tx2",
],
)
OFFSET_TRX = 46.8735
fm = focusingMirror(
name="OP-FM",
center=[0.0, 15580 - 135, sourceHeight],
surfaceToroid=("Rh", "Pt"),
materialToroid=(stripeRh, stripePt),
limPhysXToroid=(-54.0, 54.0),
limPhysYToroid=(-565.0, 565.0),
limOptXToroid=(
(43.388 + OFFSET_TRX, -4.865 + OFFSET_TRX),
(4.865 + OFFSET_TRX, -40.882 + OFFSET_TRX),
),
limOptYToroid=((-500.0, -500.0), (500.0, 500.0)),
R=[3e6, 15e6],
pitch=[1.4e-3, 4.5e-3],
r=[30, 20],
xToroid=[24.126 + OFFSET_TRX, -22 + OFFSET_TRX], # offset in local x
hToroid=[7.0, 11.3], # depth of the cylinder at x = xCylinder1 and x = xCylinder2.
jack1=[0.0, 14980.0, 0.0],
jack2=[-75.0, 16180.0, 0.0],
jack3=[75.0, 16180.0, 0.0],
tx1=[0.0, -575.0], # X-Stage 1 [x, y]
tx2=[0.0, 575.0],
) # X-Stage 2 [x, y]
# Entry wall experimental hutch: 21593 mm from source (SLS2)
# Exit window
ehWindow = filt(
name="EH-WINDOW",
center=(0.0, 22063, sourceHeight),
pitch=np.pi / 2,
limPhysX=(-10.0, 10.0),
limPhysY=(17.5, 92.5),
surface="None",
material=filterBe,
thickness=0.25,
)
ehWindow = ehWindow._replace(
surface="Beryllium window {0:0.0f} $\\mu$m".format(ehWindow.thickness * 1e3)
)
# Sample
sample = namedtuple("sample", ["name", "center"])
es1 = sample(name="ES1", center=[0, 23823, sourceHeight])
es2 = sample(name="ES2", center=[0, 25843, sourceHeight])
# Ionization chambers
ic = namedtuple("sample", ["name", "center"])
es1ic0 = ic(name="ES1 IC0", center=[0, 23633, sourceHeight])
es1ic1 = ic(name="ES1 IC1", center=[0, 24383, sourceHeight])
es1ic2 = ic(name="ES1 IC2", center=[0, 24723, sourceHeight])
Walls = namedtuple("walls", ["start", "end", "height"])
walls = Walls(start=[13472], end=[13472 + 354], height=[[-20, 36]]) # WBBS
@@ -1,82 +0,0 @@
from __future__ import annotations
import builtins
from typing import TYPE_CHECKING
from bec_lib import bec_logger
from debye_bec.devices.absorber import STATUS as ABS_STATUS
logger = bec_logger.logger
# import builtins to avoid linter errors
dev = builtins.__dict__.get("dev")
class MoveToLabelError(Exception):
"""Exception for the MoveToLabel function"""
def move_to_label():
"""
Function to move several motors to a specific position defined in the label dict.
"""
label = get_device_conditions(label="digitalTwin")
# Get absorber status and close if open
logger.info("Check Frontend Absorber Status")
abs_was_open = dev.abs.status.get() == ABS_STATUS.OPEN
if abs_was_open:
logger.info(" Close Frontend Absorber")
status = dev.abs.close()
status.wait()
# Move Frontend Slits
logger.info("Move Frontend Slits into position")
devices = ["sldi_centerx", "sldi_centery", "sldi_gapx", "sldi_gapy"]
matches = {key: label[key] for key in devices if key in label}
statuses = []
for device in matches.values():
statuses.append(device['device'].move(device['value']))
for status in statuses:
status.wait(timeout=30)
# Move Collimating mirror
logger.info("Move Collimating Mirror into position")
if "cm_rotx" in label: # pitch
logger.info(" Move pitch into position")
surveyed_movement(
axis=label['cm_rotx'],
surveyed_axes= [
{'device': dev.cm_rotz, 'abs_tol': 0.1},
]
)
# Restore absorber position
logger.info("Restore Frontend Absorber Status")
if abs_was_open:
status = dev.abs.open()
status.wait()
def surveyed_movement(axis, surveyed_axes):
"""
Moves an axis while surverying a set of axes.
Args:
axis (DeviceCondition): Device condition
surveyed_axes (list): List of dicts (same format as DeviceCondition)
Raises:
If during movement of axis, one of the surveyed axes moves out of tolerance.
"""
for surv_ax in surveyed_axes:
surv_ax['old_value'] = surv_ax['device'].read()
status = axis['device'].move(axis['value'])
while status.status == 'RUNNING':
for surv_ax in surveyed_axes:
if abs(surv_ax['device'].read() - surv_ax['old_value']) > surv_ax['abs_tol']:
axis['device'].stop()
raise MoveToLabelError(
f"During movement of {axis['device'].name}, {surv_ax['device'].name} " +
f"started to move unexpectedly (old pos: {surv_ax['old_value']}, " +
f"current pos: {surv_ax['device'].read()})"
)
@@ -34,3 +34,18 @@ to setup the prompts.
"""
# pylint: disable=invalid-name, unused-import, import-error, undefined-variable, unused-variable, unused-argument, no-name-in-module
from bec_lib import bec_logger
logger = bec_logger.logger
logger.info("Using the Debye startup script.")
from debye_bec.bec_ipython_client.plugins.auto_gain_core import AutoGainCore
from debye_bec.bec_ipython_client.plugins.digital_twin_core.digital_twin_core import DigitalTwinCore
digital_twin = DigitalTwinCore()
logger.success("Digital Twin Core loaded. Use 'digital_twin' to access it.")
auto_gain = AutoGainCore()
logger.success("Auto-Gain module loaded. Use 'auto_gain' to access it.")
@@ -0,0 +1,209 @@
"""
Auto Gain: Custom BEC widget to automatically set gains.
"""
import sys
from bec_lib import bec_logger
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme, get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
# pylint: disable=E0611
from qtpy.QtCore import QObject, QThread, Signal
from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
from ....bec_ipython_client.plugins.auto_gain_core import AutoGainCore
from ....bec_ipython_client.plugins.beamline import BeamlineId, get_beamline_id
from ..edge_selector import EdgeSelector
from .qt_widgets import CheckBox, Group, MatrixSelector, MyButton, TextIndicator
logger = bec_logger.logger
class Worker(QObject):
progress = Signal(str)
completed = Signal(int)
@SafeSlot(dict)
def start(self, config: dict):
success = False
try:
core = AutoGainCore()
self.progress.emit(f"AutoGain started with config {config}")
results = core.start(
config["element"],
config["edge"],
amplifier=config["all_amplifiers"],
link_ic0_ic1=config["link_ic0_ic1"],
comp_ring_current=config["compensate_ring_current"],
prohibited_gains=config["prohibited_gains"],
)
lines = [
f"{amp} set to gain {res['gain']:.0e}, max signal {res['max_signal']:.3f} V"
for amp, res in results.items()
]
self.progress.emit("\n".join(lines))
success = True
except Exception as exc:
logger.exception("Auto gain failed")
self.progress.emit(f"Error: {exc}")
finally:
self.completed.emit(success)
class AutoGain(BECWidget, QWidget):
"""
Main widget of auto gain widget.
"""
PLUGIN = True
ICON_NAME = "tune"
start_worker = Signal(dict)
def __init__(self, show_control: bool = True, *arg, parent=None, **kwargs):
super().__init__(parent=parent, *arg, **kwargs)
self.get_bec_shortcuts()
self.beamline = get_beamline_id()
central = QWidget()
self.root_layout = QVBoxLayout(central)
self.edge_selector_button = MyButton("Choose", "default")
self.edge_label = QLabel("No edge selected")
self.edge_group = Group(
"Edge selection", [self.edge_selector_button, self.edge_label], orientation="horizontal"
)
self.edge_element = None
self.edge_edge = None
self.edge_energy = None
self.root_layout.addWidget(self.edge_group)
self.ic0_check = CheckBox("ic0", "IC0")
self.ic1_check = CheckBox("ic1", "IC1")
self.ic2_check = CheckBox("ic2", "IC2")
self.pips_check = CheckBox("pips", "PIPS")
if self.beamline == BeamlineId.X01DA:
self.apply_group = Group(
"Apply auto gain on",
[self.ic0_check, self.ic1_check, self.ic2_check, self.pips_check],
)
else: # PIPS of X10DA has fixed gain amplifier
self.apply_group = Group(
"Apply auto gain on", [self.ic0_check, self.ic1_check, self.ic2_check]
)
self.root_layout.addWidget(self.apply_group)
self.link_ic0_ic1 = CheckBox("link_ic0_ic1", "Link IC0/IC1 gains")
self.comp_ring_current = CheckBox("comp_ring_current", "Calculate for 400 mA ring current")
self.prohibited_gains = MatrixSelector("Prohibited gains", beamline=self.beamline)
self.expert_group = Group(
"Expert settings", [self.link_ic0_ic1, self.comp_ring_current, self.prohibited_gains]
)
self.root_layout.addWidget(self.expert_group)
self.start_button = MyButton("Start", "success")
self.status = TextIndicator(label="Status")
self.status.setText("Standby")
self.status.setColor(get_accent_colors().default.name())
self.control_group = Group("Control", [self.start_button, self.status])
if show_control:
self.root_layout.addWidget(self.control_group)
self.root_layout.addStretch()
self.setLayout(self.root_layout)
self.setWindowTitle("Auto Gain")
self.edge_selector_button.clicked.connect(self._update_edge)
self.start_button.clicked.connect(self._start)
self.worker = Worker()
self.start_worker.connect(self.worker.start)
self.worker_thread = QThread()
self.worker.moveToThread(self.worker_thread)
self.worker.progress.connect(self._update_status)
self.worker.completed.connect(self._complete)
self.worker.completed.connect(self.worker_thread.quit)
def _update_edge(self, *_):
dlg = EdgeSelector(self)
if dlg.exec_():
self.edge_element = dlg.selected_element
self.edge_edge = dlg.selected_edge
self.edge_energy = dlg.selected_energy
self.edge_label.setText(
f"{dlg.selected_element}, {dlg.selected_edge}-edge, {dlg.selected_energy:0.1f} eV"
)
def _start(self):
if not self.worker_thread.isRunning():
self.start_button.setEnabled(False)
self.status.setColor(get_accent_colors().warning.name())
self.worker_thread.start()
self.start_worker.emit(self.get_config())
def _update_status(self, text):
self.status.setText(text)
def _complete(self, *_):
self.start_button.setEnabled(True)
self.status.setColor(get_accent_colors().success.name())
def load_config(self, config: dict):
self.edge_element = config["element"]
self.edge_edge = config["edge"]
self.edge_energy = config["energy"]
self.ic0_check.setChecked(config["ic0"])
self.ic1_check.setChecked(config["ic1"])
self.ic2_check.setChecked(config["ic2"])
self.pips_check.setChecked(config["pips"])
self.link_ic0_ic1.setChecked(config["link_ic0_ic1"])
self.comp_ring_current.setChecked(config["compensate_ring_current"])
self.prohibited_gains.set_selection(config["prohibited_gains"])
def get_config(self) -> dict:
if self.edge_element is None or self.edge_edge is None:
raise ValueError("Choose an absorption edge first.")
amplifier_checks = [
(self.ic0_check, "ic0"),
(self.ic1_check, "ic1"),
(self.ic2_check, "ic2"),
(self.pips_check, "pips"),
]
amplifiers = [name for check, name in amplifier_checks if check.isChecked()]
if not amplifiers:
raise ValueError("Select at least one amplifier for auto gain.")
return {
"element": self.edge_element,
"edge": self.edge_edge,
"energy": self.edge_energy,
"ic0": self.ic0_check.isChecked(),
"ic1": self.ic1_check.isChecked(),
"ic2": self.ic2_check.isChecked(),
"pips": self.pips_check.isChecked(),
"all_amplifiers": amplifiers,
"link_ic0_ic1": self.link_ic0_ic1.isChecked(),
"compensate_ring_current": self.comp_ring_current.isChecked(),
"prohibited_gains": self.prohibited_gains.selection(),
}
if __name__ == "__main__":
app = QApplication(sys.argv)
apply_theme("light")
dispatcher = BECDispatcher(gui_id="auto_gain")
win = AutoGain()
win.show()
sys.exit(app.exec_())
@@ -0,0 +1 @@
{'files': ['auto_gain.py']}
@@ -0,0 +1,57 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from bec_widgets.utils.bec_designer import designer_material_icon
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from .auto_gain import AutoGain
DOM_XML = """
<ui language='c++'>
<widget class='AutoGain' name='auto_gain'>
</widget>
</ui>
"""
class AutoGainPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = AutoGain(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon(AutoGain.ICON_NAME)
def includeFile(self):
return "auto_gain"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "AutoGain"
def toolTip(self):
return "AutoGain"
def whatsThis(self):
return self.toolTip()
@@ -0,0 +1,195 @@
from bec_widgets.utils.colors import get_accent_colors
from qtpy.QtCore import Qt
# pylint: disable=E0611
from qtpy.QtWidgets import (
QCheckBox,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLayout,
QPushButton,
QVBoxLayout,
QWidget,
)
from ....bec_ipython_client.plugins.beamline import BeamlineId
LABEL_WIDTH = 118
ROW_MARGINS = (4, 0, 4, 0)
ROW_SPACING = 6
class Group(QGroupBox):
def __init__(self, label, objs, orientation="vertical"):
super().__init__(label)
if orientation == "vertical":
self._layout = QVBoxLayout(self)
elif orientation == "horizontal":
self._layout = QHBoxLayout(self)
else:
raise ValueError(f"Orientation {orientation} is not supported!")
for obj in objs:
if isinstance(obj, QWidget):
self._layout.addWidget(obj)
elif isinstance(obj, QLayout):
self._layout.addLayout(obj)
class CheckBox(QWidget):
def __init__(self, identifier="", label=""):
super().__init__()
layout = QHBoxLayout(self)
layout.setContentsMargins(*ROW_MARGINS)
layout.setSpacing(ROW_SPACING)
self.identifier = identifier
self.label = QLabel(label)
self.label.setFixedWidth(LABEL_WIDTH)
self.label.setWordWrap(True)
layout.addWidget(self.label)
self.checkbox = QCheckBox()
layout.addWidget(self.checkbox)
def setChecked(self, set_checked: bool = True):
self.checkbox.setChecked(set_checked)
def isChecked(self):
return self.checkbox.isChecked()
class MatrixSelector(QWidget):
"""Checkbox matrix for selecting amplifier/value combinations.
Displays a grid with one row per amplifier (IC0, IC1, IC2, PIPS) and one
column per value (1e6, 1e7, 5e7, 1e8, 1e9). Each cell is a checkbox, so any
combination of rows and columns can be selected independently.
Use `selection()` to read the current state as a dict mapping the
lowercase row name to a list of the selected column values (as floats),
e.g. ``{"ic0": [1e6], "ic1": [1e6, 5e7]}``. Rows with no checked boxes are
omitted.
Attributes:
ROWS (list[str]): Row titles, in display order.
COLS (list[str]): Column titles, in display order. These are also
parsed with ``float()`` to produce the values returned by
`selection()`.
checks (dict[tuple[str, str], QCheckBox]): Checkboxes keyed by
``(row, col)`` using the original title strings.
Example:
>>> selector = MatrixSelector()
>>> my_layout.addWidget(selector)
>>> selector.selection()
{'ic0': [1000000.0], 'ic1': [1000000.0, 50000000.0]}
"""
ROWS_X01DA = ["IC0", "IC1", "IC2", "PIPS"]
COLS_X01DA = ["1e6", "1e7", "5e7", "1e8", "1e9"]
ROWS_X10DA = ["IC0", "IC1", "IC2"]
COLS_X10DA = ["1e6", "1e7", "1e8", "1e9", "1e10"]
def __init__(self, label: str, beamline: BeamlineId, parent=None):
super().__init__(parent)
if beamline == BeamlineId.X01DA:
self.cols = self.COLS_X01DA
self.rows = self.ROWS_X01DA
else:
self.cols = self.COLS_X10DA
self.rows = self.ROWS_X10DA
layout = QHBoxLayout(self)
layout.setContentsMargins(*ROW_MARGINS)
layout.setSpacing(ROW_SPACING)
self.label = QLabel(label)
self.label.setFixedWidth(LABEL_WIDTH)
self.label.setWordWrap(True)
layout.addWidget(self.label)
grid_layout = QGridLayout()
# Column headers
for c, col in enumerate(self.cols, start=1):
grid_layout.addWidget(QLabel(col), 0, c, alignment=Qt.AlignmentFlag.AlignCenter)
# Row headers + checkboxes
self.checks = {}
for r, row in enumerate(self.rows, start=1):
grid_layout.addWidget(QLabel(row), r, 0)
for c, col in enumerate(self.cols, start=1):
cb = QCheckBox()
grid_layout.addWidget(cb, r, c, alignment=Qt.AlignmentFlag.AlignCenter)
self.checks[(row, col)] = cb
layout.addLayout(grid_layout)
layout.addStretch()
def selection(self):
"""Return e.g. {"ic0": [1e6], "ic1": [1e6, 5e7]}; rows with no selection are omitted."""
result = {}
for row in self.rows:
values = [float(col) for col in self.cols if self.checks[(row, col)].isChecked()]
if values:
result[row.lower()] = values
return result
def set_selection(self, selection):
"""Set the checkboxes from a dict as returned by `selection()`.
Rows not present in `selection` are cleared. Values that don't match
any column are ignored.
"""
# Normalize keys to lowercase and values to floats for comparison
wanted = {key.lower(): {float(v) for v in values} for key, values in selection.items()}
for row in self.rows:
row_values = wanted.get(row.lower(), set())
for col in self.cols:
cb = self.checks[(row, col)]
cb.setChecked(float(col) in row_values)
class MyButton(QPushButton):
def __init__(self, text="", color="default", parent=None):
self.color = color
super().__init__(text, parent)
self.apply_theme()
def apply_theme(self):
if self.isEnabled():
colors = get_accent_colors()
color = getattr(colors, self.color).name()
self.setStyleSheet(f"QPushButton {{ background-color: {color}; color: white; }}")
else:
self.setStyleSheet(
"QPushButton {{background-color: rgb(120, 120, 120); color: white;}}"
)
def setEnabled(self, enable: bool = True):
super().setEnabled(enable)
self.apply_theme()
class TextIndicator(QWidget):
def __init__(self, label):
super().__init__()
layout = QHBoxLayout(self)
layout.setContentsMargins(*ROW_MARGINS)
layout.setSpacing(ROW_SPACING)
self.label = QLabel(label)
self.label.setFixedWidth(LABEL_WIDTH)
self.label.setWordWrap(True)
layout.addWidget(self.label)
self.text = QLabel("-")
self.text.setAlignment(Qt.AlignTop) # type: ignore
layout.addWidget(self.text)
def setLabel(self, label) -> None:
self.label.setText(label)
def setText(self, text):
self.text.setText(text)
def setColor(self, color: str):
self.text.setStyleSheet(f"QLabel {{color:{color}}}")
@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from .auto_gain_plugin import AutoGainPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(AutoGainPlugin())
if __name__ == "__main__": # pragma: no cover
main()
+128 -1
View File
@@ -13,13 +13,39 @@ logger = bec_logger.logger
_Widgets = {
"AutoGain": "AutoGain",
"DataViewer": "DataViewer",
"DigitalTwin": "DigitalTwin",
"RestartServer": "RestartServer",
"ScanControlXAS": "ScanControlXAS",
"Scheduler": "Scheduler",
}
class AutoGain(RPCBase):
"""Main widget of auto gain widget."""
_IMPORT_MODULE = "debye_bec.bec_widgets.widgets.auto_gain.auto_gain"
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class DataViewer(RPCBase):
"""Main widget of Data Viewer"""
@@ -69,7 +95,7 @@ class DigitalTwin(RPCBase):
class RestartServer(RPCBase):
"""Main widget of server restart widget"""
"""Main widget of server restart widget."""
_IMPORT_MODULE = "debye_bec.bec_widgets.widgets.restart_server.restart_server"
@@ -115,3 +141,104 @@ class ScanControlXAS(RPCBase):
"""
Take a screenshot of the dock area and save it to a file.
"""
class Scheduler(RPCBase):
"""Schedule, persist and execute a sequence of BEC scan/device commands."""
_IMPORT_MODULE = "debye_bec.bec_widgets.widgets.scheduler.scheduler"
@rpc_call
def run_schedule(self, first_item: "int" = 0):
"""
Run the schedule, continuing from wherever it last left off.
"""
@rpc_call
def abort_schedule(self):
"""
Request that the schedule stop after the current item.
"""
@rpc_call
def reset_schedule(self):
"""
Clear all execution state and start the schedule over from item 1.
"""
@rpc_call
def get_status(self) -> "dict":
"""
RPC-exposed: current schedule state, e.g. for another widget or a script.
"""
@rpc_call
def add_item(
self,
command: "str",
index: "int | None" = None,
kind: "str" = "custom",
form_state: "dict | None" = None,
pause_on_failure: "bool" = False,
) -> "str":
"""
RPC-exposed: insert a new, PENDING command into the schedule.
Safe to call while the schedule is running.
Args:
command: command text, evaluated the same way as the existing
items (against `scans`/`dev`) once the schedule runs.
index: position to insert at (0 = first). Clamped so the item
can never land before something already running or
finished. Defaults to appending at the end.
kind/form_state: optional structured description of how
`command` was built (see `schedule_item.ScheduleItem`),
used to reopen the Edit dialog pre-filled. Leave as
defaults for a plain, hand-typed command.
Returns:
The new item's item_id.
"""
@rpc_call
def edit_item(
self,
item_id: "str",
command: "str",
kind: "str" = "custom",
form_state: "dict | None" = None,
pause_on_failure: "bool" = True,
):
"""
RPC-exposed: change the command of an item that has not started
yet. Raises `RuntimeError` for an item that is already
running/finished.
"""
@rpc_call
def delete_item(self, item_id: "str"):
"""
RPC-exposed: remove an item that has not started yet. Raises
`RuntimeError` for an item that is already running/finished.
"""
@rpc_call
def move_item(self, item_id: "str", new_index: "int"):
"""
RPC-exposed: move an item that has not started yet to a new
position (0 = first, but never before something already
running/finished). Raises `RuntimeError` for an item that is
already running/finished.
"""
@rpc_call
def move_item_up(self, item_id: "str"):
"""
RPC-exposed: swap an item with the one directly before it.
"""
@rpc_call
def move_item_down(self, item_id: "str"):
"""
RPC-exposed: swap an item with the one directly after it.
"""
@@ -55,6 +55,7 @@ class DataViewer(BECWidget, QWidget):
self.current_row = 0
self.input.scan_sel.currentItemChanged_connect(self.scan_sel_changed)
self.input.scan_sel.itemDoubleClicked_connect(self.scan_sel_double_click)
self.input.load_button.clicked_connect(self.load_scan_from_history)
self.input.load_from_folder_button.clicked_connect(self.load_scan_from_folder)
self.viewer.unload_button.clicked_connect(self.unload_all_scans)
@@ -77,6 +78,12 @@ class DataViewer(BECWidget, QWidget):
"""Updates the current row value of the scan selection list"""
self.current_row = kwargs["value"]().row()
@SafeSlot()
def scan_sel_double_click(self, *_, **kwargs):
"""Updates the current row value of the scan selection list and loads the scan"""
self.current_row = kwargs["value"]().row()
self.load_scan_from_history()
@SafeSlot()
def open_in_file_manager(self, *_):
"""Open the scan folder in the systems default file manager"""
@@ -190,11 +190,11 @@ class DataView(QWidget):
if is_2d:
n_rows, _ = data.shape
row_data = data[0].astype(np.float32)
row_data = data[0].astype(np.float64)
else:
row_data = data.reshape(-1).astype(np.float32)
row_data = data.reshape(-1).astype(np.float64)
x = np.arange(row_data.size, dtype=np.float32)
x = np.arange(row_data.size, dtype=np.float64)
self.plot_widget = pg.PlotWidget()
plot_item = self.plot_widget.getPlotItem()
@@ -233,8 +233,8 @@ class DataView(QWidget):
def on_row_changed(row):
current_row_label.setText(str(row))
new_data = data[row].astype(np.float32)
new_x = np.arange(new_data.size, dtype=np.float32)
new_data = data[row].astype(np.float64)
new_x = np.arange(new_data.size, dtype=np.float64)
curve.setData(new_x, new_data)
plot_item.enableAutoRange() # type: ignore[attr-defined]
@@ -382,7 +382,7 @@ class DataView(QWidget):
for c in range(show_cols):
val = flat[r, c]
txt = (
f"{val:.6g}"
f"{val:.12g}"
if is_float
else f"{val:.4g}" if is_complex else str(val.decode()) if is_bytes else str(val)
)
@@ -105,6 +105,17 @@ class ListWidget(QWidget):
)
)
def itemDoubleClicked_connect(self, func):
"""Connect a function to Double Click event."""
self.value.itemDoubleClicked.connect(
partial(
func,
identifier=self.identifier,
value_obj=self.value,
value=lambda: self.value.currentIndex(),
)
)
def setDisabled(self, disable):
self.value.setDisabled(disable)
@@ -5,6 +5,7 @@ from __future__ import annotations
# pylint: skip-file
designer_plugins = {
"AutoGain": ("debye_bec.bec_widgets.widgets.auto_gain.auto_gain", "AutoGain"),
"DataViewer": ("debye_bec.bec_widgets.widgets.data_viewer.data_viewer", "DataViewer"),
"DigitalTwin": ("debye_bec.bec_widgets.widgets.digital_twin.digital_twin", "DigitalTwin"),
"RestartServer": (
@@ -15,11 +16,14 @@ designer_plugins = {
"debye_bec.bec_widgets.widgets.scan_control_xas.scan_control_xas",
"ScanControlXAS",
),
"Scheduler": ("debye_bec.bec_widgets.widgets.scheduler.scheduler", "Scheduler"),
}
widget_icons = {
"AutoGain": "tune",
"DataViewer": "find_in_page",
"DigitalTwin": "lightbulb",
"RestartServer": "restart_alt",
"ScanControlXAS": "tune",
"Scheduler": "assignment_add",
}
@@ -1,3 +0,0 @@
from .beamline import get_parameters
parameters = get_parameters()
@@ -0,0 +1,339 @@
"""
Find the azimuthal (phi) setting of a monochromator crystal by matching
glitches detected in a real measured spectrum against a line list exported
by XOP's MARE "spaghetti" tool.
Pipeline
--------
1. parse_xop_macro(path) -- read an XOP MARE-glitches .pro macro and
extract, for every reflection, (hkl,
alpha0, k, p2/4, fh). These four numbers
fully determine that reflection's glitch
energy as a function of azimuth phi via
XOP's own formula:
lambda(phi) = 1 / sqrt((k/cos(phi-alpha0))^2 + p2/4)
E(phi) = hc / lambda(phi)
2. detect_glitches(E, I) -- find dip positions in a real measured
(energy, intensity) scan: smooth to get a
baseline, subtract, find negative peaks
in the residual.
3. find_azimuth(...) -- for a grid of candidate phi, compute every
reflection's predicted glitch energy,
score how well the detected dip energies
line up with predicted (fh-weighted)
lines, and return the best-fit phi.
Self-test at the bottom regenerates synthetic "measured" glitches at a
known phi from the XOP line list itself and checks that find_azimuth
recovers that phi -- a good sanity check before trusting it on real data.
"""
import re
import numpy as np
from scipy.optimize import minimize_scalar
from scipy.signal import find_peaks, savgol_filter
HC = 12398.42 # eV * Angstrom
# ----------------------------------------------------------------------
# 1. Parse an XOP MARE-glitches macro
# ----------------------------------------------------------------------
def parse_xop_macro(path):
"""
Returns a list of dicts: {hkl: (h,k,l), alpha0: deg, k: float,
p2_4: float, fh: float}, one per reflection line in the macro.
XOP prints one comment line per reflection with columns:
h k l alpha0 k p2/4 th2 th2 th2 th3 th3 th3
lambda lambda lambda BrgAngU BrgAngG fh
(18 numeric columns). We parse those lines directly rather than the
'y3 = 1D/sqrt(...)' code lines -- same numbers, simpler regex.
A handful of lines in real XOP output have a formatting bug where two
columns run together with no separating space (e.g. an l=-1 column
immediately followed by a tiny alpha0 like 8.5e-7, printed as
'-18.53774E-007'). Those are detected (h,k,l must be integers) and
skipped rather than silently mis-parsed.
"""
reflections = []
skipped = 0
with open(path) as f:
for line in f:
line = line.rstrip("\n")
if not line.startswith(";"):
continue
tokens = line[1:].split()
if len(tokens) != 18:
if len(tokens) >= 4 and re.match(r"^-?\d+$", tokens[0]):
skipped += (
1 # looks like a corrupted data line (columns ran together)
)
continue # otherwise a header/comment/label line -- not data
try:
vals = [float(t) for t in tokens]
except ValueError:
continue # header row (e.g. "h k l alpha0 ...") -- not data
h, k, l = vals[0], vals[1], vals[2]
if not all(abs(x - round(x)) < 1e-6 for x in (h, k, l)):
skipped += 1 # corrupted line (merged columns)
continue
alpha0, k_param, p2_4 = vals[3], vals[4], vals[5]
fh = vals[-1]
reflections.append(
dict(
hkl=(int(h), int(k), int(l)),
alpha0=alpha0,
k=k_param,
p2_4=p2_4,
fh=fh,
)
)
print(
f"parse_xop_macro: parsed {len(reflections)} reflections, "
f"skipped {skipped} malformed line(s)"
)
return reflections
def filter_reflections(reflections, max_h=None, max_k=None, max_l=None, min_fh=None):
"""
Return only the reflections with |h|<=max_h, |k|<=max_k, |l|<=max_l
and fh>=min_fh. Any limit left as None means "no limit" on that axis.
Useful for cutting a large XOP line list (hundreds of reflections)
down to the strong, low-index ones that actually matter.
"""
out = []
for r in reflections:
h, k, l = r["hkl"]
if max_h is not None and abs(h) > max_h:
continue
if max_k is not None and abs(k) > max_k:
continue
if max_l is not None and abs(l) > max_l:
continue
if min_fh is not None and r["fh"] < min_fh:
continue
out.append(r)
return out
# ----------------------------------------------------------------------
# 2. Predict glitch energies at a given azimuth from the parsed line list
# ----------------------------------------------------------------------
def predicted_energies(reflections, phi_deg, energy_range=None):
"""
Returns (E_pred, fh, hkl_list) for every reflection whose glitch falls
at a finite, real energy at this azimuth (optionally restricted to
energy_range = (Emin, Emax)).
"""
alpha0 = np.array([r["alpha0"] for r in reflections])
kpar = np.array([r["k"] for r in reflections])
p24 = np.array([r["p2_4"] for r in reflections])
fh = np.array([r["fh"] for r in reflections])
hkl = [r["hkl"] for r in reflections]
cosval = np.cos(np.radians(phi_deg - alpha0))
with np.errstate(divide="ignore", invalid="ignore"):
term = kpar / cosval
E = HC * np.sqrt(term**2 + p24)
valid = np.isfinite(E)
if energy_range is not None:
lo, hi = energy_range
valid &= (E >= lo) & (E <= hi)
idx = np.where(valid)[0]
return E[idx], fh[idx], [hkl[i] for i in idx]
# ----------------------------------------------------------------------
# 3. Detect glitches (dips) in a real measured spectrum -- split into
# reusable steps so a GUI can recompute/redraw each one live.
# ----------------------------------------------------------------------
def sanitize_window(smooth_window, smooth_order, n_points):
"""Coerce a smoothing window into something savgol_filter will accept:
odd, >= smooth_order + 2, and not larger than the data."""
w = min(smooth_window, n_points - (1 - n_points % 2))
if w % 2 == 0:
w -= 1
w = max(w, smooth_order + 2 if (smooth_order + 2) % 2 == 1 else smooth_order + 3)
return w
def compute_baseline(energy, intensity, smooth_window=51, smooth_order=3):
"""
Sorts by energy and returns (energy_sorted, intensity_sorted, baseline,
residual). This is step 1 (smoothing) on its own, so a GUI can redraw
the baseline/residual live as the window/order controls move.
"""
energy = np.asarray(energy, dtype=float)
intensity = np.asarray(intensity, dtype=float)
order = np.argsort(energy)
energy, intensity = energy[order], intensity[order]
w = sanitize_window(smooth_window, smooth_order, len(intensity))
baseline = savgol_filter(intensity, w, smooth_order)
residual = intensity - baseline
return energy, intensity, baseline, residual
def find_dips(energy, residual, prominence=None, prominence_sigma=3.0):
"""
Step 2 (peak finding) on its own, so a GUI can redraw just the
detected-dip markers as the prominence control moves, without
recomputing the baseline. Returns (glitch_energies, prominences,
prominence_used).
"""
if prominence is None:
prominence = prominence_sigma * np.std(residual)
peaks, props = find_peaks(-residual, prominence=prominence)
return energy[peaks], props["prominences"], prominence
def detect_glitches(
energy,
intensity,
smooth_window=51,
smooth_order=3,
prominence=None,
prominence_sigma=3.0,
):
"""
Convenience wrapper chaining compute_baseline + find_dips for
non-interactive use (see find_crystal_azimuth). Returns
(glitch_energies, prominences) as before.
"""
energy, intensity, baseline, residual = compute_baseline(
energy, intensity, smooth_window, smooth_order
)
glitch_E, prom, _ = find_dips(energy, residual, prominence, prominence_sigma)
return glitch_E, prom
# ----------------------------------------------------------------------
# 4. Score a candidate azimuth against the detected glitches
# ----------------------------------------------------------------------
def azimuth_score(phi_deg, reflections, detected_energies, energy_range, sigma_eV=3.0):
"""
For each detected glitch energy, find the best-matching predicted
line (weighted by its fh, discounted by a Gaussian in energy
distance) and sum those best-match scores. High score = many detected
dips coincide with real, strong predicted lines at this phi.
"""
E_pred, fh, _ = predicted_energies(reflections, phi_deg, energy_range)
if len(E_pred) == 0 or len(detected_energies) == 0:
return 0.0
diffs = detected_energies[:, None] - E_pred[None, :]
gauss = np.exp(-0.5 * (diffs / sigma_eV) ** 2)
weighted = gauss * fh[None, :]
return weighted.max(axis=1).sum()
# ----------------------------------------------------------------------
# 5. Main search: scan phi, then refine locally
# ----------------------------------------------------------------------
def find_azimuth(
reflections, detected_energies, energy_range, phi_grid=None, sigma_eV=3.0
):
"""
Returns (phi_best, score_best, phi_grid, scores) so the coarse scan
can be plotted as a diagnostic (a clean single maximum is a good
sign; multiple comparable peaks means the data is ambiguous, e.g. too
few glitches in range, or a real point-group ambiguity).
"""
detected_energies = np.asarray(detected_energies, dtype=float)
if phi_grid is None:
phi_grid = np.arange(0.0, 180.0, 0.5)
scores = np.array(
[
azimuth_score(p, reflections, detected_energies, energy_range, sigma_eV)
for p in phi_grid
]
)
i_best = int(np.argmax(scores))
phi_coarse = phi_grid[i_best]
res = minimize_scalar(
lambda p: -azimuth_score(
p, reflections, detected_energies, energy_range, sigma_eV
),
bounds=(phi_coarse - 0.1, phi_coarse + 0.1),
method="bounded",
)
return res.x, -res.fun, phi_grid, scores
# ----------------------------------------------------------------------
# 6. End-to-end convenience wrapper
# ----------------------------------------------------------------------
def find_crystal_azimuth(
macro_path,
energy,
intensity,
energy_range,
smooth_window=51,
smooth_order=3,
prominence=None,
sigma_eV=3.0,
phi_grid=None,
):
"""
One-call pipeline: parse the XOP macro, detect glitches in the
measurement, and return the best-fit azimuth.
"""
reflections = parse_xop_macro(macro_path)
glitch_E, glitch_prom = detect_glitches(
energy, intensity, smooth_window, smooth_order, prominence
)
print(
f"find_crystal_azimuth: detected {len(glitch_E)} candidate glitches "
f"in [{energy_range[0]}, {energy_range[1]}] eV"
)
phi_best, score_best, phi_grid, scores = find_azimuth(
reflections, glitch_E, energy_range, phi_grid, sigma_eV
)
return dict(
phi_deg=phi_best,
score=score_best,
glitch_energies=glitch_E,
glitch_prominences=glitch_prom,
phi_grid=phi_grid,
scores=scores,
reflections=reflections,
)
# ----------------------------------------------------------------------
# Self-test: regenerate synthetic glitches at a KNOWN phi from the macro
# itself, then check find_azimuth recovers it. No real measurement needed.
# ----------------------------------------------------------------------
if __name__ == "__main__":
import sys
macro_path = sys.argv[1] if len(sys.argv) > 1 else "sample_xop.pro"
true_phi = float(sys.argv[2]) if len(sys.argv) > 2 else -20.0
energy_range = (4000.0, 12000.0)
reflections = parse_xop_macro(macro_path)
E_true, fh_true, hkl_true = predicted_energies(reflections, true_phi, energy_range)
# keep only reasonably strong lines, as a real measurement would only
# show glitches with non-negligible structure factor
keep = fh_true > np.percentile(fh_true, 50) if len(fh_true) else []
synthetic_glitches = E_true[keep]
print(
f"Self-test: {len(synthetic_glitches)} synthetic glitches at true phi={true_phi}"
)
phi_best, score_best, phi_grid, scores = find_azimuth(
reflections, synthetic_glitches, energy_range
)
print(
f"Self-test: recovered phi = {phi_best:.2f} deg "
f"(true = {true_phi} deg), score = {score_best:.2f}"
)
@@ -1,297 +0,0 @@
"""
Calculates the positions of axes based on a beamline config
"""
import numpy as np
from bec_lib import bec_logger
from .. import parameters as bl
from ..types import BeamlineId, ConfigDict
logger = bec_logger.logger
def calc_positions(beamline: BeamlineId, cfg: ConfigDict) -> dict[str, dict[str, float]]:
"""
Calculates the positions of axes based on a beamline config.
Args:
cfg(ConfigDict): Dictionary with beamline config
Returns:
dict[str, dict[str, float]]: Dictionary mapping device names to dictionaries
containing a "value" key with the corresponding float value (position).
"""
pos = {}
## FE slits
trxr = -np.arctan(cfg["h_acc"]) * bl.feSlits.center1[1]
trxw = (
(np.arctan(cfg["h_acc"]) * bl.feSlits.center1[1])
/ bl.feSlits.center1[1]
* bl.feSlits.center2[1]
)
tryb = -np.arctan(cfg["v_acc"]) * bl.feSlits.center1[1]
tryt = (
(np.arctan(cfg["v_acc"]) * bl.feSlits.center1[1])
/ bl.feSlits.center1[1]
* bl.feSlits.center2[1]
)
xgap = trxw - trxr
ygap = tryt - tryb
pos["sldi_gapx"] = {"value": xgap}
pos["sldi_gapy"] = {"value": ygap}
## Collimating Mirror
obj_dist = bl.cm.center[1] # object distance
beam_vs = 2 * obj_dist * np.tan(cfg["v_acc"]) # vertical size of beam after CM
# TRX
if cfg["cm_stripe"] in bl.cm.surface:
index = bl.cm.surface.index(cfg["cm_stripe"])
else:
raise ValueError(f"Requested stripe {cfg['cm_stripe']} not found in parameters!")
cm_trx = -(bl.cm.limOptX[0][index] + bl.cm.limOptX[1][index]) / 2
pos["cm_trx"] = {"value": cm_trx}
# TRY
height = obj_dist * np.tan(cfg["v_acc"]) ** 2 * 1 / np.tan(cfg["cm_pitch"])
pos["cm_try"] = {"value": height}
# Pitch
pos["cm_rotx"] = {
"value": -cfg["cm_pitch"] * 1e3
} # invert and convert to mrad (same as EGU of rotx axis)
# Bending Radius
radius = (
2.0 * obj_dist / np.sin(cfg["cm_pitch"])
) # Elements of modern X-ray Physics, page 108 ff.
pos["cm_bnd_radius"] = {"value": radius * 1e-6} # Convert to km
## Monochromator
if cfg["mo1_mode"] == "Monochromatic":
# Add 2x CM pitch to the bragg angle
bragg = cfg["mo1_bragg"]
elif cfg["mo1_mode"] == "Pinkbeam":
# Align xtal surfaces parallel to beam
bragg = 0
else:
raise ValueError("Monochromator mode not supported")
pos["mo1_bragg_angle"] = {"value": bragg / np.pi * 180} # Bragg angle in deg
# TRY, Height
l = bl.mo1.xtalGap[0] / np.sin(cfg["mo1_bragg"])
yhor = l * np.cos(2.0 * (cfg["mo1_bragg"] + cfg["cm_pitch"]))
yver = yhor * np.tan(2.0 * cfg["cm_pitch"])
if cfg["mo1_mode"] == "Monochromatic":
beam_offset_mo1 = (
l * np.sin(2.0 * (cfg["mo1_bragg"] + cfg["cm_pitch"])) - yver
) # Resultat ist korrekt!
elif cfg["mo1_mode"] == "Pinkbeam":
beam_offset_mo1 = 0
else:
raise ValueError("Monochromator mode not supported")
def csc(a):
return 1 / np.sin(a)
def cot(a):
return 1 / np.tan(a)
# calculate height of center of first crystal surface
f = bl.mo1.rotOffset # rotation offset, mm
d = bl.mo1.heightOffset # xtal height offset, mm
c = d * csc(cfg["mo1_bragg"]) - f * cot(cfg["mo1_bragg"])
# Calculate height of center of rotation
b = np.sqrt(
d**2 * csc(cfg["mo1_bragg"]) ** 2
- 2 * d * f * cot(cfg["mo1_bragg"]) * csc(cfg["mo1_bragg"])
+ f**2 * cot(cfg["mo1_bragg"]) ** 2
+ f**2
)
h = np.cos(np.pi / 2 - np.arctan(f / c) - cfg["mo1_bragg"] - 2 * cfg["cm_pitch"]) * b
h2 = ((bl.mo1.center[1] - bl.cm.center[1]) - np.sqrt(b**2 - h**2)) * np.tan(2 * cfg["cm_pitch"])
height_mo1_real = (
h + h2
) # per design, the height should not change if the pitch of the CM is not changed!
if cfg["mo1_mode"] == "Monochromatic":
pass
elif cfg["mo1_mode"] == "Pinkbeam":
height_mo1_real = (
height_mo1_real - 13
) # Move down to let beam pass between both crystal without touching copper cooler
else:
raise ValueError("Monochromator mode not supported")
pos["mo1_try"] = {"value": height_mo1_real}
# TRX, Crystal selection
if cfg["mo1_mode"] == "Monochromatic":
xtal = cfg["mo1_xtal"].translate(
str.maketrans("", "", "()")
) # Remove brackets from xtal name to conform with parameters
if xtal in bl.mo1.xtal:
index = bl.mo1.xtal.index(xtal)
else:
raise ValueError(f"Requested xtal {xtal} not found in parameters!")
pos["mo1_trx"] = {"value": bl.mo1.xtalOffsetX[index]}
else:
pos["mo1_trx"] = {"value": 0}
diag = bl.mo1.xtalGap[0] / np.sin(cfg["mo1_bragg"]) # Calculations for Mono
dz = diag * np.cos(2 * (cfg["cm_pitch"] + cfg["mo1_bragg"]))
## Slits 1
d = bl.opSlits1.center[1] - bl.cm.center[1] - dz
sl1_beam_height = d * np.tan(2 * cfg["cm_pitch"]) + beam_offset_mo1
pos["sl1_centery"] = {"value": sl1_beam_height}
pos["sl1_gapy"] = {"value": beam_vs}
## Beam Monitor 1
d = bl.opBM1.center[1] - bl.cm.center[1] - dz
bm1_beam_height = d * np.tan(2 * cfg["cm_pitch"]) + beam_offset_mo1
pos["bm1_try"] = {"value": bm1_beam_height}
## Focusing Mirror
p = bl.fm.center[1]
q = cfg["smpl"] - bl.fm.center[1]
f = (p * q) / (p + q) # focal length
# Bender radius
if cfg["fm_qy"] is None:
radius = 2 * q / np.sin(cfg["fm_rotx"]) # ideal bending radius for focused beam
else:
radius = (
2 * cfg["fm_qy"] / np.sin(cfg["fm_rotx"])
) # ideal bending radius for unfocused beam
pos["fm_bnd_radius"] = {"value": radius * 1e-6} # Convert to km
# Pitch
d = bl.fm.center[1] - bl.cm.center[1] - dz
fm_rotx = (
2 * cfg["cm_pitch"] - cfg["fm_rotx"]
) # calculate pitch in absolute values (according to horizontal plane)
pos["fm_rotx"] = {
"value": -fm_rotx * 1e3
} # invert and convert to mrad (same as EGU of rotx axis)
if cfg["fm_stripe"] in ("Rh (toroid)", "Pt (toroid)"):
# TRY
if cfg["fm_stripe"] == "Rh (toroid)":
r = bl.fm.r[0]
h_cyl = bl.fm.hToroid[0]
else: # PT toroid
r = bl.fm.r[1]
h_cyl = bl.fm.hToroid[1]
width_beam = 2 * bl.fm.center[1] * np.tan(cfg["h_acc"] * 1e-3)
alpha = np.arccos(1 - width_beam**2 / (2 * r**2))
h = r - (r * np.cos(alpha / 2))
fm_beam_height = (d * np.tan(2 * cfg["cm_pitch"]) + beam_offset_mo1) * cfg["fm_gain_height"]
fm_height = (d * np.tan(2 * cfg["cm_pitch"]) + beam_offset_mo1 - h_cyl + h / 2) * cfg[
"fm_gain_height"
]
pos["fm_try"] = {"value": fm_height}
# TRX
if cfg["fm_stripe"] == "Rh (toroid)":
x_cyl = -bl.fm.xToroid[0]
else:
x_cyl = -bl.fm.xToroid[1]
pos["fm_trx"] = {"value": x_cyl}
elif cfg["fm_stripe"] in ("Rh (flat)", "Pt (flat)"):
# TRY
fm_height = (d * np.tan(2 * cfg["cm_pitch"]) + beam_offset_mo1) * cfg["fm_gain_height"]
fm_beam_height = fm_height
pos["fm_try"] = {"value": fm_height}
# TRX
if cfg["fm_stripe"] == "Rh (flat)":
x_flat = -bl.fm.xFlat[0]
else:
x_flat = -bl.fm.xFlat[1]
pos["fm_trx"] = {"value": x_flat}
else:
raise ValueError("FM Stripe selection not valid")
pos["fm_roty"] = {"value": 0}
pos["fm_rotz"] = {"value": 0}
## Slits 2
if hasattr(bl, "opSlits2"):
d = bl.opSlits2.center[1] - bl.fm.center[1]
sl2_beam_height = fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"]))
pos["sl2_centery"] = {"value": sl2_beam_height}
pos["sl2_gapy"] = {"value": beam_vs}
## Beam Monitor 2
d = bl.opBM2.center[1] - bl.fm.center[1]
bm2_beam_height = fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"]))
pos["bm2_try"] = {"value": bm2_beam_height}
## Optical Table
if beamline == "x01da":
# TRY
d = bl.ehWindow.center[1] - bl.fm.center[1]
ot_height = fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"]))
pos["ot_try"] = {"value": ot_height}
# Pitch
ot_pitch = -(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"])
pos["ot_rotx"] = {"value": ot_pitch * 1e3}
# TRZ ES1
ot_es1_trz = cfg["smpl"]
pos["ot_es1_trz"] = {"value": ot_es1_trz}
# ES0 exit window
pos["es0wi_try"] = {
"value": 5
} # At 5mm, the middle of the window is 500 mm from the table (neutral position)
else:
# Exit window height
d = bl.ehWindow.center[1] - bl.fm.center[1]
es0wi_try = fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"]))
pos["es0wi_try"] = {"value": es0wi_try}
# ES1 table height
d = bl.es1.center[1] - bl.fm.center[1]
es1_try = fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"]))
pos["es1_try"] = {"value": es1_try}
# IC0 height
d = bl.es1ic0.center[1] - bl.fm.center[1]
es1ic0_try = (
fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"])) - es1_try
)
pos["es1ic0_try"] = {"value": es1ic0_try}
# IC1 height
d = bl.es1ic1.center[1] - bl.fm.center[1]
es1ic1_try = (
fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"])) - es1_try
)
pos["es1ic1_try"] = {"value": es1ic1_try}
# IC2 height
d = bl.es1ic2.center[1] - bl.fm.center[1]
es1ic2_try = (
fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"])) - es1_try
)
pos["es1ic2_try"] = {"value": es1ic2_try}
# ES2 table height
d = bl.es2.center[1] - bl.fm.center[1]
es2_try = fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"]))
pos["es2_try"] = {"value": es2_try}
return pos
@@ -1,70 +0,0 @@
"""
Calculates the sideview coordinates based on a beamline config.
"""
import numpy as np
from .. import parameters as bl
from ..types import ConfigDict, DataDict
def calc_sideview(cfg: ConfigDict) -> DataDict:
"""
Calculates the sideview coordinates based on a beamline config.
Args:
cfg(ConfigDict): Dictionary with beamline config
Returns:
DataDict: Sideview data
"""
beam: DataDict = {"x": [], "y": []}
beam["x"] = []
beam["y"] = []
beam["x"].append(0) # Source
beam["y"].append(bl.sourceHeight)
beam["x"].append(bl.cm.center[1]) # CM
beam["y"].append(bl.sourceHeight)
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"]))
dz = diag * np.cos(2 * (cfg["cm_pitch"] + cfg["mo1_bragg"]))
beam["x"].append(bl.mo1.center[1] - dz / 2) # Mono 1.1
beam["y"].append(
bl.sourceHeight
+ np.tan(2 * cfg["cm_pitch"]) * (bl.mo1.center[1] - dz / 2 - bl.cm.center[1])
)
beam["x"].append(bl.mo1.center[1] + dz / 2) # Mono 1.2
beam["y"].append(
bl.sourceHeight
+ np.tan(2 * cfg["cm_pitch"]) * (bl.mo1.center[1] - dz / 2 - bl.cm.center[1])
+ dy
)
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] - dz)
+ dy
)
beam["x"].append(cfg["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])
)
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["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])
)
return beam
@@ -1,159 +0,0 @@
"""
Calculates the surface coordinates based on a beamline config.
"""
import re
import numpy as np
from bec_lib import bec_logger
from .. import parameters as bl
from ..types import ConfigDict, SurfaceDict
logger = bec_logger.logger
def calc_surfaces(cfg: ConfigDict) -> SurfaceDict:
"""
Calculates the surface coordinates based on a beamline config.
Args:
cfg(ConfigDict): Dictionary with beamline config
Returns:
SurfaceDict: Surface data
"""
out: SurfaceDict = {
"cm": {"x": [], "y": []},
"mo1_1": {"x": [], "y": []},
"mo1_2": {"x": [], "y": []},
"fm": {"x": [], "y": []},
}
# Collimating mirror
l = 2 * bl.cm.center[1] * np.tan(cfg["v_acc"]) / np.sin(cfg["cm_pitch"])
w1 = 2 * (bl.cm.center[1] - l / 2) * np.tan(cfg["h_acc"])
w2 = 2 * (bl.cm.center[1] + l / 2) * np.tan(cfg["h_acc"])
# index = bl.cm.surface.index(cfg["cm_stripe"])
cen = -cfg["cm_trx"]
out["cm"]["x"] = [cen - w1 / 2, cen - w2 / 2, cen + w2 / 2, cen + w1 / 2]
out["cm"]["y"] = [-l / 2, l / 2, l / 2, -l / 2]
# Monochromator
# calculate height of center of first crystal surface
c = bl.mo1.heightOffset * 1 / np.sin(cfg["mo1_bragg"]) - bl.mo1.rotOffset * 1 / np.tan(
cfg["mo1_bragg"]
)
e = bl.mo1.xtalGap[0] / np.tan(cfg["mo1_bragg"]) - c
xtal = cfg["mo1_xtal"].translate(
str.maketrans("", "", "()")
) # Remove brackets from xtal name to conform with parameters
index = bl.mo1.xtal.index(xtal)
xtal_pos = bl.mo1.xtalOffsetX[index]
xtal_length_1 = bl.mo1.xtalLength1[index]
xtal_length_2 = bl.mo1.xtalLength2[index]
width_beam = 2 * bl.mo1.center[1] * np.tan(cfg["h_acc"])
height_beam = 2 * bl.cm.center[1] * np.tan(cfg["v_acc"])
w = height_beam / np.sin(cfg["mo1_bragg"])
if cfg["mo1_mode"] == "Monochromatic":
out["mo1_1"]["x"] = [
xtal_pos - width_beam / 2,
xtal_pos + width_beam / 2,
xtal_pos + width_beam / 2,
xtal_pos - width_beam / 2,
]
out["mo1_1"]["y"] = [
xtal_length_1 / 2 - c - w / 2,
xtal_length_1 / 2 - c - w / 2,
xtal_length_1 / 2 - c + w / 2,
xtal_length_1 / 2 - c + w / 2,
]
out["mo1_2"]["x"] = [
xtal_pos - width_beam / 2,
xtal_pos + width_beam / 2,
xtal_pos + width_beam / 2,
xtal_pos - width_beam / 2,
]
out["mo1_2"]["y"] = [
-xtal_length_2 / 2 + e - w / 2,
-xtal_length_2 / 2 + e - w / 2,
-xtal_length_2 / 2 + e + w / 2,
-xtal_length_2 / 2 + e + w / 2,
]
else: # Pinkbeam
out["mo1_1"]["x"] = []
out["mo1_1"]["y"] = []
out["mo1_2"]["x"] = []
out["mo1_2"]["y"] = []
if cfg["fm_stripe"] is None:
return out
# Focusing mirror
if cfg["fm_stripe"] in ("Rh (toroid)", "Pt (toroid)"):
surface = bl.fm.surfaceToroid
stripe = re.sub(r"\s*\(.*?\)", "", cfg["fm_stripe"]).strip()
index = surface.index(stripe)
r = bl.fm.r[index]
else:
surface = bl.fm.surfaceFlat
stripe = re.sub(r"\s*\(.*?\)", "", cfg["fm_stripe"]).strip()
index = surface.index(stripe)
r = bl.fm.r[index]
off = -cfg["fm_trx"]
width_beam = 2 * bl.fm.center[1] * np.tan(cfg["h_acc"])
if cfg["fm_stripe"] in ("Rh (toroid)", "Pt (toroid)"):
l = height_beam / np.sin(cfg["fm_rotx"])
alpha = np.arccos(1 - width_beam**2 / (2 * r**2))
h = r - (r * np.cos(alpha / 2))
z = h / np.tan(cfg["fm_rotx"])
x = [off - width_beam / 2, off - width_beam / 2]
y = [l / 2 - z / 2, -l / 2 - z / 2]
res = 20
x_elipse = np.linspace(0, np.pi, res)
y_elipse = np.linspace(0, np.pi, res)
x_elipse = [-width_beam / 2 * np.cos(i) + off for i in x_elipse]
y_elipse = [width_beam * np.sin(i) * z / width_beam - l / 2 - z / 2 for i in y_elipse]
x.extend(x_elipse)
y.extend(y_elipse)
x.extend([off + width_beam / 2, off + width_beam / 2])
y.extend([-l / 2 - z / 2, l / 2 - z / 2])
res = 50
x_elipse = np.linspace(np.pi, 0, res)
y_elipse = np.linspace(np.pi, 0, res)
x_elipse = [-width_beam / 2 * np.cos(i) + off for i in x_elipse]
y_elipse = [width_beam * np.sin(i) * z / width_beam + l / 2 - z / 2 for i in y_elipse]
x.extend(x_elipse)
y.extend(y_elipse)
out["fm"]["x"] = x
out["fm"]["y"] = y
else: # flat surface, no toroid
l = height_beam / np.sin(cfg["fm_rotx"])
w1 = 2 * (bl.fm.center[1] - l / 2) * np.tan(cfg["h_acc"])
w2 = 2 * (bl.fm.center[1] + l / 2) * np.tan(cfg["h_acc"])
out["fm"]["x"] = [off - w1 / 2, off + w1 / 2, off + w2 / 2, off - w2 / 2]
out["fm"]["y"] = [-l / 2, -l / 2, l / 2, l / 2]
return out
@@ -1,519 +0,0 @@
"""
Various calculations for the digital twin
"""
import re
from typing import Literal, cast
import numpy as np
from bec_lib import bec_logger
from scipy.interpolate import UnivariateSpline
from xrt.backends.raycing.physconsts import AVOGADRO, CHeVcm
from .. import parameters as bl
logger = bec_logger.logger
H = 6.62606957e-34
E = 1.602176634e-19
C = 299792458
RE = 2.8179e-15
def sldi_gap_to_acc(sldi_gapx: float, sldi_gapy: float) -> tuple[float, float]:
"""
Calculate the slits acceptance based on the gap values
Args:
sldi_gapx(float): GAPX value of the slits in mm
sldi_gapy(float): GAPY value of the slits in mm
Returns:
tuple[float, float]: Horizontal and vertical acceptance in rad
"""
d1 = bl.feSlits.center1[1]
d2 = bl.feSlits.center2[1]
h_acc = np.tan(sldi_gapx / (d2 + d1))
v_acc = np.tan(sldi_gapy / (d2 + d1))
return h_acc, v_acc
def cm_trx_to_stripe(cm_trx: float) -> str | None:
"""
Based on the trx value of the collimating mirror, return
the correct stripe
Args:
cm_trx(float): Collimating mirror trx value
Returns
str | None: Stripe of the mirror, None if not found
"""
cm_stripe = None
for name, low, high in zip(bl.cm.surface, bl.cm.limOptX[0], bl.cm.limOptX[1]):
if low <= cm_trx <= high:
cm_stripe = name
return cm_stripe
def cm_stripe_to_trx(cm_stripe: str) -> float | None:
"""
Based on the stripe of the collimating mirror, return
the trx value
Args:
cm_stripe(str): Stripe of the collimating mirror
Returns:
float | None: TRX value of the stripe. None if not found
"""
for name, low, high in zip(bl.cm.surface, bl.cm.limOptX[0], bl.cm.limOptX[1]):
if cm_stripe == name:
return -(low + high) / 2
return None
def fm_trx_to_stripe(fm_trx: float) -> str | None:
"""
Based on the trx value of the focusing mirror, return
the correct stripe
Args:
fm_trx(float): focusing mirror trx value
Returns
str | None: Stripe of the mirror, None if not found
"""
fm_stripe = None
if hasattr(bl.fm, "surfaceFlat"):
for name, low, high in zip(bl.fm.surfaceFlat, bl.fm.limOptXFlat[1], bl.fm.limOptXFlat[0]):
if low <= fm_trx <= high:
fm_stripe = name + " (flat)"
for name, low, high in zip(bl.fm.surfaceToroid, bl.fm.limOptXToroid[1], bl.fm.limOptXToroid[0]):
if low <= fm_trx <= high:
fm_stripe = name + " (toroid)"
return fm_stripe
def fm_stripe_to_trx(fm_stripe: str) -> float | None:
"""
Based on the stripe of the focusing mirror, return
the trx value
Args:
fm_stripe(str): Stripe of the focusing mirror
Returns:
float | None: TRX value of the stripe. None if not found
"""
if hasattr(bl.fm, "surfaceFlat"):
for name, low, high in zip(bl.fm.surfaceFlat, bl.fm.limOptXFlat[1], bl.fm.limOptXFlat[0]):
if fm_stripe == name + " (flat)":
return (low + high) / 2
for name, low, high in zip(bl.fm.surfaceToroid, bl.fm.limOptXToroid[1], bl.fm.limOptXToroid[0]):
if fm_stripe == name + " (toroid)":
return -(low + high) / 2
return None
def mo1_energy_resolution(xtal: Literal["Si111", "Si311"], energy: float) -> float:
"""
Calculate the energy resolution of the monochromator
Args:
xtal(str): Xtal name. "Si111" or "Si311"
energy(float): Energy in eV
Returns:
float: Energy resolution in eV
"""
index = bl.mo1.xtal.index(xtal)
crystal = bl.mo1.material1[index]
dtheta = np.linspace(-30, 90, 601)
theta = crystal.get_Bragg_angle(energy) + dtheta * 1e-6
refl = np.abs(crystal.get_amplitude(energy, np.sin(theta))[0]) ** 2 # single crystal
refl2 = refl**2 # DCM with parallel crystals
# FWHM of the DCM curve
spline = UnivariateSpline(dtheta, refl2 - refl2.max() / 2, s=0)
roots = cast(np.ndarray, spline.roots())
r1, r2 = float(roots[0]), float(roots[1])
fwhm_rad = (r2 - r1) * 1e-6 # µrad → rad
# Energy resolution
theta_b = crystal.get_Bragg_angle(energy)
de_over_e = fwhm_rad / np.tan(theta_b)
de = de_over_e * energy
# logger.info(f"DCM FWHM : {r2-r1:.2f} µrad")
# logger.info(f"ΔE/E : {dE_over_E:.2e}")
# logger.info(f"ΔE : {dE:.3f} eV at {E} eV")
return de
def cm_reflectivity(cm_stripe: str, cm_pitch: float, energy: float) -> float:
"""
Calculate the reflectivity of the mirror stripe based
on the pitch and energy.
Args:
cm_stripe(str): Mirror stripe
cm_pitch(float): Pitch of the mirror (beam incidence angle)
energy(float): Energy of the beam in eV
Returns:
float: Reflectivity [0-1]
"""
if cm_stripe is None:
return np.nan
index = bl.cm.surface.index(cm_stripe)
rs, _ = bl.cm.material[index].get_amplitude(energy, np.sin(cm_pitch))[0:2]
refl = abs(rs) ** 2
return refl
def fm_reflectivity(fm_stripe: str, fm_pitch: float, energy: float) -> float:
"""
Calculate the reflectivity of the mirror stripe based
on the pitch and energy.
Args:
cm_stripe(str): Mirror stripe
cm_pitch(float): Pitch of the mirror (beam incidence angle)
energy(float): Energy of the beam in eV
Returns:
float: Reflectivity [0-1]
"""
if fm_stripe is None:
return np.nan
if fm_stripe in ("Rh (toroid)", "Pt (toroid)"):
surface = bl.fm.surfaceToroid
material = bl.fm.materialToroid
stripe = re.sub(r"\s*\(.*?\)", "", fm_stripe).strip()
index = surface.index(stripe)
else:
surface = bl.fm.surfaceFlat
material = bl.fm.materialFlat
stripe = re.sub(r"\s*\(.*?\)", "", fm_stripe).strip()
index = surface.index(stripe)
rs, _ = material[index].get_amplitude(energy, np.sin(fm_pitch))[0:2]
refl = abs(rs) ** 2
return refl
def mo1_bragg_angle(
mo_mode: Literal["Monochromatic", "Pinkbeam"], d_spacing: float, energy: float, cm_pitch: float
) -> tuple[float, float]:
"""
Calculate the bragg angle of the monochromator.
Corrects for the collimating mirror pitch.
Args:
mo_mode(str): Monochromator mode. "Monochromatic" or "Pinkbeam"
d_spacing(float): D-spacing of the crystal in Angstrom
energy(float): Energy of the beam in eV
cm_pitch(float): Pitch of collimating mirror in rad
Returns:
tuple[float, float]: Bragg angle and corrected bragg angle
"""
wl = C * H / (E * energy)
val = wl / (2 * d_spacing * 1e-10)
bragg_angle = 0
if val > -1 and val < 1:
bragg_angle = np.asin(val)
if mo_mode == "Monochromatic":
# Add 2x CM pitch to the bragg angle
bragg_angle_cor = (2 * cm_pitch) + bragg_angle
else:
# Align xtal surfaces parallel to beam
bragg_angle_cor = 2 * cm_pitch
return bragg_angle, bragg_angle_cor
def fm_ideal_pitch(
fm_focus: Literal["Defocused", "Focused", "Manual"],
fm_stripe: str,
smpl: float,
sldi_hacc: float | None = None,
sldi_vacc: float | None = None,
fm_focx: float | None = None,
fm_focy: float | None = None,
) -> tuple[float, float | None]:
"""
Calculates the ideal pitch for the focusing mirror depending on the
focusing strategy.
If "Defocused" is chosed, sldi_hacc, sldi_vacc, fm_focx and fm_focy
must be provided.
Args:
fm_focus(str): Focus strategy. "Defocused", "Focused" or "Manual
fm_stripe(str): Mirror stripe
smpl(float): Sample position in mm from source
sldi_hacc(float): Horizontal acceptance of frontend slits. Defaults to None
sldi_vacc(float): Vertical acceptance of frontend slits. Defaults to None
fm_focx(float): Requested horizontal spot size in mm. Defaults to None
fm_focy(float): Requested vertical spot size in mm. Defaults to None
Returns:
tuple[float, float | None]: Pitch of mirror in rad, qy in mm
"""
# logger.info("Calculate pitch and qy now...")
# logger.info(f"sldi_hacc: {sldi_hacc}")
# logger.info(f"sldi_vacc: {sldi_vacc}")
# logger.info(f"fm_stripe: {fm_stripe}")
# logger.info(f"smpl: {smpl}")
p_cm = bl.cm.center[1] # posCM
p = bl.fm.center[1] # posFM
q = smpl - bl.fm.center[1] # dist posFM to posEX
if fm_focus == "Defocused":
assert sldi_hacc is not None, "sldi_hacc must be provided for Defocused mode"
assert sldi_vacc is not None, "sldi_vacc must be provided for Defocused mode"
assert fm_focx is not None, "fm_focx must be provided for Defocused mode"
assert fm_focy is not None, "fm_focy must be provided for Defocused mode"
a = 2 * np.tan(sldi_hacc) * bl.fm.center[1] # Beam width at focusing mirror
# logger.info(f"a: {a}")
# logger.info(f"sldi_hacc: {sldi_hacc}")
# logger.info(f"bl.fm.center[1]: {bl.fm.center[1]}")
# logger.info(f"p: {p}")
# logger.info(f"q: {q}")
b = (
2 * np.tan(sldi_vacc) * bl.cm.center[1]
) # Beam height at focusing mirror (collimated beam)
x = fm_focx
# logger.info(f"x: {x}")
x = 0.098821 * x**2 + 0.512344 * x # polynom to correct for spot size
# logger.info(f"x (corrected): {x}")
y = fm_focy
y = 3.183562 * y**2 + 1.258364 * y # polynom to correct for spot size
qx = q + x * p / a
qy = q + y * p_cm / b
f = (p * qx) / (p + qx) # focal length
# logger.info(f"qx: {qx}")
# logger.info(f"f: {f}")
else: # Calculate for focused beam on sample in "manual" and "focused" mode
qy = None
f = (p * q) / (p + q) # focal length
pitch = 0
if "Rh" in fm_stripe:
pitch = np.arcsin(bl.fm.r[0] / (2 * f)) # ideal pitch for FM
if "Pt" in fm_stripe:
pitch = np.arcsin(bl.fm.r[1] / (2 * f)) # ideal pitch for FM
# logger.info(f"fm_pitch: {pitch}")
# logger.info(f"qy: {qy}")
return pitch, qy
def calc_beamsize(
sldi_hacc: float,
sldi_vacc: float,
fm_stripe: str,
fm_pitch: float,
fm_radius: float,
smpl: float,
) -> tuple[float, float | None]:
"""
Calculate the resulting beamsize according to the input parameters
Args:
sldi_hacc(float): Horizontal acceptance of frontend slits
sldi_vacc(float): Vertical acceptance of frontend slits
fm_stripe(str): Mirror stripe
fm_pitch(float): Focusing mirror pitch in rad
fm_radius(float): Focusing mirror bender radius in m
smpl(float): Sample position in mm from source
Returns:
tuple[float, float | None]: horizontal spot size, vertical spot size, both in mm
"""
# logger.info("Calculate beamsize now...")
# logger.info(f"sldi_hacc: {sldi_hacc}")
# logger.info(f"sldi_vacc: {sldi_vacc}")
# logger.info(f"fm_stripe: {fm_stripe}")
# logger.info(f"fm_pitch: {fm_pitch}")
# logger.info(f"fm_radius: {fm_radius}")
# logger.info(f"smpl: {smpl}")
p_cm = bl.cm.center[1] # posCM
p = bl.fm.center[1] # posFM
q = smpl - bl.fm.center[1] # dist posFM to posEX
qy = fm_radius * np.sin(fm_pitch) / 2
a = 2 * np.tan(sldi_hacc) * bl.fm.center[1] # Beam width at focusing mirror
b = 2 * np.tan(sldi_vacc) * bl.cm.center[1] # Beam height at focusing mirror (collimated beam)
f = 0
if "Rh" in fm_stripe:
f = bl.fm.r[0] / (2 * np.sin(fm_pitch))
if "Pt" in fm_stripe:
f = bl.fm.r[1] / (2 * np.sin(fm_pitch))
qx = p * f / (p - f)
x = a * (qx - q) / p
y = b * (qy - q) / p_cm
# Change this | to a plus if calculation is not correct
fm_focx = -4 * (64043 - 125000 * np.sqrt(0.26249637 + 0.395284 * x)) / 98821
# Change this | to a plus if calculation is not correct
fm_focy = -1 * (314591 - 250000 * np.sqrt(1.58347995 + 12.734248 * y)) / 1591781
# logger.info(f"f: {f}")
# logger.info(f"qx: {qx}")
# logger.info(f"qy: {qy}")
# logger.info(f"fm_focx: {fm_focx}")
# logger.info(f"fm_focy: {fm_focy}")
return fm_focx, fm_focy
def cm_critical_angle(cm_stripe: Literal["Si", "Pt", "Rh"], energy) -> float:
"""
Calculate the critical angle of the mirror stripe
Args:
cm_stripe(str): Mirror stripe. "Si", "Pt" or "Rh"
energy(float): Energy in eV
Returns:
float: Critical angle in rad
"""
if cm_stripe == "Si":
stripe = bl.stripeSi
elif cm_stripe == "Pt":
stripe = bl.stripePt
else:
stripe = bl.stripeRh
w = CHeVcm / 100 / energy # convert energy [eV] to wavelength [m]
f1 = stripe.elements[0].Z + np.real(stripe.elements[0].get_f1f2(energy))
number_density = stripe.rho * 1e3 * AVOGADRO / (stripe.elements[0].mass / 1e3)
critical_angle = np.sqrt(number_density * RE * w**2 * f1 / np.pi)
return critical_angle
def mirror_surface_geometries(
mirror: Literal["cm", "fm_toroid", "fm_flat"],
) -> dict[str, tuple[float, float, float, float]]:
"""
Return the mirror stripe geometries
Args:
mirror(str): Mirror. "cm", "fm_toroid" or "fm_flat"
Returns:
dict[str, tuple[float, float, float, float]]: Dictionary mapping surface
names to tuples of (x, y, width, height).
"""
if mirror == "cm":
surface = bl.cm.surface
lim_opt_x = bl.cm.limOptX
lim_opt_y = bl.cm.limOptY
elif mirror == "fm_toroid":
surface = bl.fm.surfaceToroid
lim_opt_x = bl.fm.limOptXToroid
lim_opt_y = bl.fm.limOptYToroid
elif mirror == "fm_flat":
surface = bl.fm.surfaceFlat
lim_opt_x = bl.fm.limOptXFlat
lim_opt_y = bl.fm.limOptYFlat
else:
raise ValueError(f"Requested mirror {mirror} not available!")
geom = {}
for sf, lx, hx, ly, hy in zip(surface, lim_opt_x[0], lim_opt_x[1], lim_opt_y[0], lim_opt_y[1]):
geom[sf] = (lx, ly, hx - lx, hy - ly)
return geom
def mo_surface_geometries(
mo: Literal["mo1"], plane: Literal[0, 1]
) -> dict[str, tuple[float, float, float, float]]:
"""
Return the monochromator xtal geometries
Args:
mo(str): Monochromator. Only "mo1" implemented
plane(int): Surface of xtal. 0 and 1 (First and second)
Returns:
dict[str, tuple[float, float, float, float]]: Dictionary mapping surface
names to tuples of (x, y, width, height).
"""
if mo == "mo1":
xtal = bl.mo1.xtal
xtal_width = bl.mo1.xtalWidth
xtal_offset_x = bl.mo1.xtalOffsetX
if plane == 0:
xtal_length = bl.mo1.xtalLength1
else:
xtal_length = bl.mo1.xtalLength2
else:
return {}
geom = {}
for sf, w, offx, length in zip(xtal, xtal_width, xtal_offset_x, xtal_length):
geom[sf] = (offx - w / 2, -length / 2, w, length)
return geom
def wall_geometries() -> list[list[float]]:
"""
Return the wall geometries
Returns:
list[list[float]]: List of [x, y, width, height] geometry values for each wall.
"""
geom = []
if not hasattr(bl, "walls"):
return geom
for i, _ in enumerate(bl.walls.start):
geom.append(
[
bl.walls.start[i],
bl.walls.height[i][0],
bl.walls.end[i] - bl.walls.start[i],
bl.walls.height[i][1] - bl.walls.height[i][0],
]
)
return geom
def pipe_geometries() -> list[dict[str, np.ndarray]]:
"""
Return the wall geometries
Returns:
list[dict[str, np.ndarray]]: List of dictionaries with keys "x" and "y",
each containing a numpy array of two float values representing
the start and end coordinates of the pipe top and bottom edges.
"""
pipes = []
if not hasattr(bl, "vacuum_pipes"):
return pipes
for i, _ in enumerate(bl.vacuum_pipes.center):
top = bl.vacuum_pipes.center[i] + bl.vacuum_pipes.diameter[i] / 2 + bl.sourceHeight
bottom = bl.vacuum_pipes.center[i] - bl.vacuum_pipes.diameter[i] / 2 + bl.sourceHeight
pipes.append(
{
"x": np.array([bl.vacuum_pipes.start[i], bl.vacuum_pipes.end[i]]),
"y": np.array([top, top]),
}
)
pipes.append(
{
"x": np.array([bl.vacuum_pipes.start[i], bl.vacuum_pipes.end[i]]),
"y": np.array([bottom, bottom]),
}
)
return pipes
def table_to_smpl_pos(table: str) -> float:
"""
Return the sample position based on the table name.
Args:
table (str): Table name, e.g. ES1 or ES2
"""
if table == bl.es1.name:
return bl.es1.center[1]
if table == bl.es2.name:
return bl.es2.center[1]
raise ValueError(f"Table {table} not found in beamline parameter file")
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,171 @@
"""
GlitchesPreview: QDialog to preview monochromator glitches
"""
from pathlib import Path
import numpy as np
import pyqtgraph as pg
import xraydb
from bec_lib import bec_logger
from bec_widgets.utils.colors import Colors, get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
# pylint: disable=E0611
from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from .azimuth_calculator import parse_xop_macro, predicted_energies
from .widgets.qt_widgets import ComboBox, InputNumberField
logger = bec_logger.logger
EV_BELOW = 200
EV_ABOVE = 1000
def edge_step(energies, edge_energy, width_ev=1.5):
"""Plain logistic step 0 -> 1 (no EXAFS/pre-edge), for a readable
visual reference of where a real edge would fall."""
x = np.clip(-(energies - edge_energy) / width_ev, -500, 500)
return 1.0 / (1.0 + np.exp(x))
class GlitchesPreview(QDialog):
"""QDialog to preview monochromator glitches"""
def __init__(
self,
parent,
current_xtal: str,
current_azm: float,
ranges: dict,
element: str | None = "",
edge: str | None = "",
energy: float = 12000,
):
super().__init__(parent)
self.setWindowTitle("Glitches Preview")
if element != "":
self.energy = xraydb.xray_edge(element, edge, energy_only=True)
self.energies = np.linspace(self.energy - EV_BELOW, self.energy + EV_ABOVE, 1000)
self.mu = edge_step(self.energies, self.energy)
else:
self.energy = energy
self.energies = np.linspace(self.energy - EV_BELOW, self.energy + EV_ABOVE, 1000)
self.mu = np.ones(len(self.energies))
layout = QVBoxLayout(self)
text = QLabel("Preview of monochromator glitches.")
layout.addWidget(text)
self.xtal_selector = ComboBox("mo1_xtal", "Crystal", ["Si(111)", "Si(311)"])
self.xtal_selector.set_current_text(current_xtal)
self.xtal_selector.activated_connect(self.change_xtal)
self.old_xtal = current_xtal
layout.addWidget(self.xtal_selector)
self.azm_angle = current_azm
self.ranges = ranges
self.azm_input = None
if ranges[current_xtal]["ll"] is not None and ranges[current_xtal]["hl"] is not None:
self.azm_input = InputNumberField(
"azm_angle",
"Azm. Angle",
unit="deg",
init=self.azm_angle,
decimals=3,
single_step=0.01,
ll=ranges[current_xtal]["ll"],
hl=ranges[current_xtal]["hl"],
)
self.azm_input.value_changed_connect(self.update_plot)
layout.addWidget(self.azm_input)
accept_text = "Select crystal and azimuthal angle"
else:
accept_text = "Select crystal"
self.plot = pg.PlotWidget()
self.plot.setLabel("bottom", "Energy", units="eV")
self.plot.setLabel("left", "Beam Intensity")
self.plot.addLegend()
self.plot.setYRange(-0.15, 1.2)
color = Colors.golden_angle_color(colormap="plasma", num=1, format="HEX")[0]
self.curve_edge = self.plot.plot(pen=pg.mkPen(color=color, width=2))
layout.addWidget(self.plot)
button_layout = QHBoxLayout()
self._cancel_button = QPushButton("Close")
self._cancel_button.clicked.connect(self.reject)
self._cancel_button.setStyleSheet(
f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}"
)
self._select_button = QPushButton(accept_text)
self._select_button.clicked.connect(self.accept)
self._select_button.setStyleSheet(
f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}"
)
self._cancel_button.setAutoDefault(False)
self._cancel_button.setDefault(False)
self._select_button.setAutoDefault(False)
self._select_button.setDefault(False)
button_layout.addStretch()
button_layout.addWidget(self._cancel_button)
button_layout.addWidget(self._select_button)
button_layout.addStretch()
layout.addLayout(button_layout)
self.adjustSize()
self.setFixedSize(self.sizeHint())
self.reflections_si111 = parse_xop_macro(
Path(__file__).with_name("xop_si111_Si-LN2_8-8-8_4500-60000eV_-180-180deg.pro")
)
self.reflections_si311 = parse_xop_macro(
Path(__file__).with_name("xop_si311_Si-LN2_8-8-8_4500-60000eV_0-360deg.pro")
)
self.update_plot()
@SafeSlot()
def change_xtal(self, *_, **__):
diff = self.azm_angle - self.ranges[self.old_xtal]["ll"]
xtal_info = self.ranges[self.xtal_selector.currentText()]
self.azm_input.set_range(xtal_info["ll"], xtal_info["hl"])
self.azm_input.set_number(xtal_info["ll"] + diff)
self.old_xtal = self.xtal_selector.currentText()
self.update_plot()
@SafeSlot()
def update_plot(self, *_, **__):
if isinstance(self.azm_input, InputNumberField):
self.azm_angle = self.azm_input.value()
if self.xtal_selector.currentText() == "Si(111)":
reflections = self.reflections_si111
else:
reflections = self.reflections_si311
e_pred, fh, _ = predicted_energies(
reflections, self.azm_angle, (self.energies[0], self.energies[-1])
)
if len(fh) == 0:
self.curve_edge.setData(self.energies, self.mu)
logger.info(f"phi = {self.azm_angle:.1f} deg | 0 glitches in window")
return
logw = np.log10(fh)
wnorm = (
(logw - logw.min()) / (logw.max() - logw.min())
if logw.max() > logw.min()
else np.ones_like(logw)
)
mu = self.mu
for e, wn in zip(e_pred, wnorm):
depth = 0.02 + 0.08 * wn
mu = mu - depth * np.exp(-0.5 * ((self.energies - e) / 1.5) ** 2)
self.curve_edge.setData(self.energies, mu)
@@ -7,7 +7,7 @@ from typing import Union
# pylint: disable=E0611
from qtpy.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from ..types import BeamlineId
from .....bec_ipython_client.plugins.beamline import BeamlineId
from ..widgets.qt_widgets import (
Button,
ComboBox,
@@ -113,8 +113,32 @@ class InputPanel(QWidget):
self.mo1_xtal = ComboBox("mo1_xtal", "Crystal", ["Si(111)", "Si(311)"])
self.mo1_bragg_angle = NumberIndicator("Bragg Angle", "deg", decimals=1)
self.mo1_eres = NumberIndicator("Energy Resolution", "eV", decimals=2)
if beamline == BeamlineId.X01DA:
self.mo1_azm_angle = InputNumberField(
"azm_angle",
"Azm. Angle",
unit="deg",
init=0,
decimals=3,
single_step=0.01,
ll=-10,
hl=10,
)
else:
self.mo1_azm_angle = NumberIndicator("Azm. Angle", "deg", decimals=3)
self.mo1_glitch_preview = Button("Glitches Preview", "Open", enabled=True)
self.mo1_ass_group = Group(
"Monochromator", [self.mo1_mode, self.mo1_xtal, self.mo1_bragg_angle, self.mo1_eres]
"Monochromator",
[
self.mo1_mode,
self.mo1_xtal,
self.mo1_bragg_angle,
self.mo1_eres,
self.mo1_azm_angle,
self.mo1_glitch_preview,
],
)
# Focusing Mirror
@@ -173,7 +197,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.es1man_trz])
# Assemble complete assistant group
self.input_group = Group(
@@ -186,25 +248,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"])
@@ -7,24 +7,27 @@ from typing import Literal
# pylint: disable=E0611
from qtpy.QtWidgets import QVBoxLayout, QWidget
from ..types import BeamlineId
from .....bec_ipython_client.plugins.beamline import BeamlineId
from ..widgets.move_widget import AbsorberWidget, MoveWidget
from ..widgets.qt_widgets import Group
from ..widgets.qt_widgets import Button, Group, QLabel
class MoverPanel(QWidget):
""" "Panel to move an axis to a certain position"""
def __init__(self, beamline: BeamlineId, dev, parent=None):
def __init__(self, core, beamline: BeamlineId, dev, parent=None):
super().__init__(parent)
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(4, 4, 4, 4)
self._layout.setSpacing(4)
self.core = core
self.mover_widgets = []
# FE Slits
self.sldi_gapx = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="sldi_gapx",
@@ -36,6 +39,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.sldi_gapx)
self.sldi_gapy = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="sldi_gapy",
@@ -55,6 +59,7 @@ class MoverPanel(QWidget):
# Collimating mirror
self.cm_trx = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="cm_trx",
@@ -66,6 +71,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.cm_trx)
self.cm_try = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="cm_try",
@@ -77,6 +83,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.cm_try)
self.cm_bnd = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="cm_bnd",
@@ -88,6 +95,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.cm_bnd)
self.cm_rotx = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="cm_rotx",
@@ -104,6 +112,7 @@ class MoverPanel(QWidget):
# Monochromator
self.mo1_bragg_angle = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="mo1_bragg_angle",
@@ -115,6 +124,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.mo1_bragg_angle)
self.mo1_trx = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="mo1_trx",
@@ -126,6 +136,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.mo1_trx)
self.mo1_try = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="mo1_try",
@@ -136,12 +147,30 @@ class MoverPanel(QWidget):
)
self.mover_widgets.append(self.mo1_try)
self.mo1_mov_group = Group(
"Monochromator", [self.mo1_bragg_angle, self.mo1_trx, self.mo1_try]
)
if beamline == BeamlineId.X01DA:
self.mo1_roty = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="mo1_roty",
label="ROTY",
unit="deg",
decimals=3,
deadband=0.01,
)
self.mover_widgets.append(self.mo1_roty)
self.mo1_mov_group = Group(
"Monochromator", [self.mo1_bragg_angle, self.mo1_trx, self.mo1_try, self.mo1_roty]
)
else:
self.mo1_mov_group = Group(
"Monochromator", [self.mo1_bragg_angle, self.mo1_trx, self.mo1_try]
)
# OP Slits 1
self.sl1_centery = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="sl1_centery",
@@ -153,6 +182,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.sl1_centery)
self.sl1_gapy = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="sl1_gapy",
@@ -167,6 +197,7 @@ class MoverPanel(QWidget):
# OP Beam Monitor 1
self.bm1_try = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="bm1_try",
@@ -181,6 +212,7 @@ class MoverPanel(QWidget):
# Focusing Mirror
self.fm_trx = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="fm_trx",
@@ -192,6 +224,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.fm_trx)
self.fm_try = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="fm_try",
@@ -203,6 +236,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.fm_try)
self.fm_bnd = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="fm_bnd",
@@ -214,6 +248,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.fm_bnd)
self.fm_rotx = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="fm_rotx",
@@ -225,6 +260,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.fm_rotx)
self.fm_roty = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="fm_roty",
@@ -236,6 +272,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.fm_roty)
self.fm_rotz = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="fm_rotz",
@@ -254,6 +291,7 @@ class MoverPanel(QWidget):
if beamline == "x01da":
# OP Slits 2
self.sl2_centery = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="sl2_centery",
@@ -265,6 +303,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.sl2_centery)
self.sl2_gapy = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="sl2_gapy",
@@ -279,6 +318,7 @@ class MoverPanel(QWidget):
# OP Beam Monitor 2
self.bm2_try = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="bm2_try",
@@ -294,6 +334,7 @@ class MoverPanel(QWidget):
if beamline == "x01da":
# Optical Table
self.ot_try = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="ot_try",
@@ -305,6 +346,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.ot_try)
self.ot_rotx = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="ot_rotx",
@@ -319,6 +361,7 @@ class MoverPanel(QWidget):
# Experimental Station 0
self.es0wi_try = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="es0wi_try",
@@ -334,6 +377,7 @@ class MoverPanel(QWidget):
# Experimental Station 1
if beamline == "x01da":
self.ot_es1_trz = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="ot_es1_trz",
@@ -343,9 +387,21 @@ class MoverPanel(QWidget):
deadband=5,
)
self.mover_widgets.append(self.ot_es1_trz)
self.es1man_trz = MoveWidget(
core=self.core,
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(
core=self.core,
beamline=beamline,
dev=dev,
motor="es1_try",
@@ -357,6 +413,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.es1_try)
self.es1ic0_try = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="es1ic0_try",
@@ -368,6 +425,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.es1ic0_try)
self.es1ic1_try = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="es1ic1_try",
@@ -379,6 +437,7 @@ class MoverPanel(QWidget):
self.mover_widgets.append(self.es1ic1_try)
self.es1ic2_try = MoveWidget(
core=self.core,
beamline=beamline,
dev=dev,
motor="es1ic2_try",
@@ -390,15 +449,29 @@ 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(
core=self.core,
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(
core=self.core,
beamline=beamline,
dev=dev,
motor="es2_try",
@@ -409,8 +482,18 @@ 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])
# Multi-Mover
self.multi_move = Button(label_button="Move Selection", enabled=True, color="success")
self.multi_move_info = QLabel(
"Not all motors can move together at the same time, digital twin will take care of that."
)
self.multi_move_group = Group("Multi Move", [self.multi_move, self.multi_move_info])
# Assemble complete mover group
if beamline == "x01da":
self.mover_group = Group(
@@ -428,6 +511,8 @@ class MoverPanel(QWidget):
self.ot_mov_group,
self.es0_mov_group,
self.es1_mov_group,
self.es2_mov_group,
self.multi_move_group,
],
)
else:
@@ -445,6 +530,7 @@ class MoverPanel(QWidget):
self.es0_mov_group,
self.es1_mov_group,
self.es2_mov_group,
self.multi_move_group,
],
)
@@ -15,13 +15,11 @@ from qtpy.QtGui import QBrush, QColor
# pylint: disable=E0611
from qtpy.QtWidgets import QApplication, QGraphicsRectItem, QHBoxLayout, QVBoxLayout, QWidget
from ..calculations.calc_varia import (
mirror_surface_geometries,
mo_surface_geometries,
pipe_geometries,
wall_geometries,
from .....bec_ipython_client.plugins.digital_twin_core.types import (
BeamlineId,
DataDict,
SurfaceDict,
)
from ..types import BeamlineId, DataDict, SurfaceDict
from ..widgets.qt_widgets import Group
logger = bec_logger.logger
@@ -30,9 +28,10 @@ logger = bec_logger.logger
class SurfacePlots(QWidget):
"""Plot widget with two curves and legend."""
def __init__(self, beamline: BeamlineId, parent=None):
def __init__(self, beamline: BeamlineId, core, parent=None):
super().__init__(parent=parent)
self.beamline = beamline
self.core = core
self._layout = QHBoxLayout(self)
self._layout.setContentsMargins(4, 4, 4, 4)
self._layout.setSpacing(6)
@@ -169,15 +168,15 @@ class SurfacePlots(QWidget):
for name, plot in self.plots.items():
if name == "cm":
plot_surface(plot["widget"], mirror_surface_geometries("cm"))
plot_surface(plot["widget"], self.core.mirror_surface_geometries("cm"))
elif name == "mo1_1":
plot_surface(plot["widget"], mo_surface_geometries("mo1", 0))
plot_surface(plot["widget"], self.core.mo_surface_geometries("mo1", 0))
elif name == "mo1_2":
plot_surface(plot["widget"], mo_surface_geometries("mo1", 1))
plot_surface(plot["widget"], self.core.mo_surface_geometries("mo1", 1))
elif name == "fm":
if self.beamline == "x01da":
plot_surface(plot["widget"], mirror_surface_geometries("fm_flat"))
plot_surface(plot["widget"], mirror_surface_geometries("fm_toroid"))
plot_surface(plot["widget"], self.core.mirror_surface_geometries("fm_flat"))
plot_surface(plot["widget"], self.core.mirror_surface_geometries("fm_toroid"))
else:
raise ValueError(f"Plot {name} not found!")
for name, plot in self.plots.items():
@@ -203,8 +202,9 @@ class SurfacePlots(QWidget):
class SideviewPlot(QWidget):
"""Plot widget with two curves and legend."""
def __init__(self, parent=None):
def __init__(self, core, parent=None):
super().__init__(parent=parent)
self.core = core
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(4, 4, 4, 4)
self._layout.setSpacing(0)
@@ -303,7 +303,7 @@ class SideviewPlot(QWidget):
def plot_vacuum_pipes(self):
"""Plot vacuum pipes"""
pipes = pipe_geometries()
pipes = self.core.pipe_geometries()
for pipe in pipes:
self.pipes.append(
self.plot_widget.plot(
@@ -313,7 +313,7 @@ class SideviewPlot(QWidget):
def plot_walls(self):
"""Plot walls"""
walls = wall_geometries()
walls = self.core.wall_geometries()
for wall in walls:
rect = QGraphicsRectItem(wall[0], wall[1], wall[2], wall[3])
rect.setBrush(QBrush(QColor(*self.color_impenetrable)))
@@ -27,5 +27,20 @@ class SettingsPanel(QWidget):
"Axes Offsets", [self.load_offsets, self.offsets_status, self.show_offsets]
)
self._layout.addWidget(self.offset_group)
self.load_from_scan = Button(
label="Load from Scan", label_button="Load from Scan", enabled=True
)
self.load_from_config = Button(
label="Load from Config", label_button="Load from Config", enabled=True
)
self.save_config = Button(label="Save Config", label_button="Save Config", enabled=True)
self.import_export_group = Group(
"Import/Export", [self.load_from_scan, self.load_from_config, self.save_config]
)
# Assemble complete settings group
self.settings_group = Group("Settings", [self.offset_group, self.import_export_group])
self._layout.addWidget(self.settings_group)
self._layout.addStretch()
@@ -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,24 +10,24 @@ 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 qtpy.QtWidgets import QApplication, QCheckBox, QHBoxLayout, QLabel, QPushButton, QWidget
# pylint: disable=E0402
from .....devices.absorber import STATUS as ABS_STATUS
from ..types import BeamlineId
from .....bec_ipython_client.plugins.digital_twin_core.motion_worker import (
MotionOutcome,
MotionWorker,
)
logger = bec_logger.logger
class Status:
"""Status class for the axis"""
IN_POSITION = "in_position" # green mdi.check-circle
NOT_IN_POSITION = "not_in_position" # orange mdi.close-circle
MOVING = "moving" # blue mdi.loading (spinning)
ERROR = "error" # red mdi.alert-circle
IN_POSITION = "in_position" # green
NOT_IN_POSITION = "not_in_position" # orange
QUEUED = "queued" # grey, waiting for another motor group
MOVING = "moving" # blue
ERROR = "error" # red
class StatusIcon(QWidget):
@@ -44,6 +43,7 @@ class StatusIcon(QWidget):
Status.NOT_IN_POSITION: ("cancel", "#e6d922"),
Status.ERROR: ("warning", "#e74c3c"),
Status.MOVING: ("cycle", "#2980b9"),
Status.QUEUED: ("hourglass_empty", "#7f8c8d"),
}
def __init__(self, parent=None):
@@ -123,223 +123,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):
position_changed = Signal(float)
error = Signal()
waiting = Signal() # must wait for another subgroup
started = Signal() # slot acquired, motion begins
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
"""
try:
if alias:
self.motor = alias
if abs_closed:
if self.dev.abs.status.get() == ABS_STATUS.OPEN:
status = self.dev.abs.close()
# TODO Set timeout to 0.001 and check if it actually raises
# (it should not start motion).
# Check of behavior of digital twin afterwards.
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:
self.error.emit()
self.finished.emit()
class MoveWidget(QWidget):
"""
@@ -350,10 +140,13 @@ class MoveWidget(QWidget):
- Start / Stop button
"""
def __init__(
self, beamline: BeamlineId, dev, motor, label: str = "", unit=None, decimals=3, deadband=0.0
):
_STATUS_TOOLTIPS = {Status.QUEUED: "Waiting for other motors to finish"}
def __init__(self, core, beamline, dev, motor, label="", unit=None, decimals=3, deadband=0.0):
super().__init__()
self.core = core
self._cancel: threading.Event | None = None
self.fb = 0.0
self.target = 0
self.beamline = beamline
@@ -361,8 +154,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)
@@ -373,6 +167,11 @@ class MoveWidget(QWidget):
layout.setContentsMargins(4, 0, 4, 0)
layout.setSpacing(4)
# Checkbox
self.checkbox = QCheckBox()
self.checkbox.setChecked(True)
layout.addWidget(self.checkbox)
# Name
self.label = QLabel(label)
self.label.setFixedWidth(76)
@@ -437,6 +236,11 @@ class MoveWidget(QWidget):
+ f"{{background-color: {get_accent_colors().emergency.name()}; color: white;}}"
)
@property
def is_busy(self) -> bool:
"""True from start until the finished signal was handled (moving OR queued)."""
return self._thread is not None
def set_target(self, target):
"""Change the target value in the ui"""
self.target = target
@@ -473,13 +277,13 @@ class MoveWidget(QWidget):
)
def _set_status(self, status: str):
"""Set the current status icon in the ui"""
self.status = status
self.status_icon.set_status(status)
self.status_icon.setToolTip(self._STATUS_TOOLTIPS.get(status, ""))
def _on_target_or_fb_changed(self):
"""Re-evaluate in-position status whenever the target value changes."""
if self.status in (Status.ERROR, Status.MOVING):
if self.status in (Status.ERROR, Status.MOVING, Status.QUEUED):
return
if abs(self.fb - self.target) <= self.deadband:
self._set_status(Status.IN_POSITION)
@@ -487,45 +291,86 @@ class MoveWidget(QWidget):
self._set_status(Status.NOT_IN_POSITION)
def _on_button_clicked(self):
"""Starts or stops motion depending on current situation"""
if self._thread and self._thread.isRunning():
if self.is_busy:
self._stop_motion()
else:
self._start_motion()
self.start_motion()
def _start_motion(self):
"""Start a motion"""
target = self.target
if abs(target - self.fb) <= self.deadband:
def start_motion(self, abort: threading.Event | None = None):
"""Start a motion. `abort` is shared by a batch (Move Selection) so that one failure
prevents the not-yet-started motors of that batch from starting."""
if self.is_busy:
return
if abs(self.target - self.fb) <= self.deadband:
self._set_status(Status.IN_POSITION)
return
self._set_status(Status.MOVING)
self._apply_button_style("stop")
self._cancel = threading.Event()
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)
signals = _WorkerSignals()
signals.waiting.connect(self._on_waiting)
signals.started.connect(self._on_started)
signals.position_changed.connect(self._on_position_changed)
signals.error.connect(self._on_error)
signals.finished.connect(self._on_motion_finished)
signals.finished.connect(self._cleanup_thread)
self._signals = signals
# No on_finished here: _run emits it exactly once (MotionWorker.run never calls
# it for motors that are "not integrated in digital twin").
worker = MotionWorker(
self.beamline,
self.dev,
self.motor,
self.target,
on_position_changed=signals.position_changed.emit,
on_error=signals.error.emit,
)
self._worker = worker
self._thread = threading.Thread(
target=self._run, args=(worker, signals, self._cancel, abort), daemon=True
)
self._thread.start()
def _run(self, worker, signals, cancel, abort):
try:
outcome = self.core.run_exclusive(...)
if outcome == MotionOutcome.FAILED and worker.last_exception is not None:
signals.error.emit() # harmless if motion() already emitted it
finally:
signals.finished.emit()
try:
self.core.run_exclusive(
worker,
abort=abort,
cancel=cancel,
on_waiting=signals.waiting.emit,
on_started=signals.started.emit,
)
except Exception:
signals.error.emit()
raise
finally:
signals.finished.emit()
def _on_waiting(self):
self._set_status(Status.QUEUED)
def _on_started(self):
self._set_status(Status.MOVING)
def _on_error(self):
"""Called when an error occurs"""
self._set_status(Status.ERROR)
self._apply_button_style("start")
def _stop_motion(self):
"""Attempts to stop the motion"""
"""Stop a running motor, or remove a queued one from the queue."""
if self._cancel:
self._cancel.set() # releases a waiting motor within ~0.1 s
if self._worker:
self._worker.stop()
self._worker.stop() # stops a running motor
def _on_position_changed(self, pos: float):
"""Change the feedback value in the ui"""
@@ -547,20 +392,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):
@@ -106,10 +106,20 @@ class InputNumberField(QWidget):
if prefix is not None:
self.val.setPrefix(prefix + " ")
layout.addWidget(self.val)
self.ll = ll
self.hl = hl
def set_number(self, number):
self.val.setValue(number)
def set_range(self, ll, hl):
self.val.setRange(ll, hl)
self.ll = ll
self.hl = hl
def get_range(self):
return (self.ll, self.hl)
def has_focus(self) -> bool:
return self.val.hasFocus()
@@ -164,9 +174,14 @@ class ComboBox(QWidget):
def setDisabled(self, disable):
self.value.setDisabled(disable)
def get_all_texts(self) -> list[str]:
return [self.value.itemText(i) for i in range(self.value.count())]
class Button(QWidget):
def __init__(self, label=None, label_button: str = "", enabled=False):
def __init__(
self, label=None, label_button: str = "", enabled: bool = False, color: str = "default"
):
super().__init__()
layout = QHBoxLayout(self)
layout.setContentsMargins(*ROW_MARGINS)
@@ -176,28 +191,35 @@ class Button(QWidget):
self.label.setFixedWidth(LABEL_WIDTH)
layout.addWidget(self.label)
self.button = QPushButton(label_button)
self.enable_button(enabled)
self.setEnabled(enabled)
layout.addWidget(self.button)
self.color = color
def clicked_connect(self, func):
"""Connect a function to the button press."""
self.button.clicked.connect(func)
def enable_button(self, enable: bool = False):
if enable:
self.button.setStyleSheet(
f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}"
)
self.button.setEnabled(True)
else: # disabled
self.button.setStyleSheet(
"QPushButton {{background-color: rgb(120, 120, 120); color: white;}}"
)
self.button.setDisabled(True)
def setEnabled(self, enable: bool = True):
self.button.setEnabled(enable)
self.apply_theme()
def setText(self, text):
self.button.setText(text)
def setColor(self, color: str):
self.color = color
self.apply_theme()
def apply_theme(self):
if self.button.isEnabled():
colors = get_accent_colors()
color = getattr(colors, self.color).name()
self.button.setStyleSheet(f"QPushButton {{ background-color: {color}; color: white; }}")
else:
self.button.setStyleSheet(
"QPushButton {{background-color: rgb(120, 120, 120); color: white;}}"
)
class TextIndicator(QWidget):
def __init__(self, label):
@@ -21,10 +21,6 @@ logger = bec_logger.logger
class RestartServer(BECWidget, QWidget):
"""
Main widget of server restart widget.
This widget has no visible UI of its own - it only drives the confirmation/
progress dialog. The surrounding dock panel is hidden as soon as we can find
it, so the user only ever sees the dialog, never an empty "Restart Server" tab.
"""
PLUGIN = True
@@ -37,6 +37,8 @@ from ..edge_selector import EdgeSelector
logger = bec_logger.logger
ALLOWED_SCANS = [
"acquire",
"line_scan",
"xas_simple_scan",
"xas_simple_scan_with_xrd",
"xas_advanced_scan",
Whitespace-only changes.
@@ -0,0 +1,61 @@
"""
Redis endpoint(s) for the schedule widget plugin.
Design notes (why this looks the way it does)
-----------------------------------------------
BEC never touches Redis with raw keys/commands. Every channel is described
by an `EndpointInfo` (endpoint string + message type + allowed operations,
see `bec_lib.endpoints`), and `RedisConnector` enforces both: calling an
operation that isn't in the endpoint's `MessageOp` raises
`IncompatibleRedisOperation`, and passing a message that isn't an instance
of the endpoint's declared `message_type` raises
`IncompatibleMessageForEndpoint`. Plain string topics still work but are
deprecated. So a "conforming" custom endpoint means building a real
`EndpointInfo`, exactly like `bec_lib.endpoints.MessageEndpoints` does
internally.
Namespace: we use `EndpointType.USER` ("user/...”), the same prefix BEC's
own `MessageEndpoints.scan_queue_schedule()` uses for user-writable,
persisted data (as opposed to `internal/`, `public/`, etc.).
Message type: we deliberately do NOT declare our own `BECMessage`
subclass. BEC's msgpack codec (`bec_lib.codecs.BECMessageEncoder.decode`)
resolves an incoming message's class by name via
`getattr(bec_lib.messages, type_name)` - i.e. only classes physically
defined inside `bec_lib.messages` are resolvable this way. A message class
declared in plugin code would fail to deserialize unless you monkey-patch
it into that module, which is exactly the kind of side-door this plugin
is meant to avoid. Instead we reuse `bec_lib.messages.VariableMessage`,
a first-class, exported message type built for carrying an arbitrary
(msgpack-serializable) payload under `.value`.
`bec_lib.script_executor.upload_script` persists a script's text in Redis
the same way, for the same reason - it's the established BEC pattern for
"a plugin needs to stash its own structured data in Redis".
"""
from __future__ import annotations
from bec_lib.endpoints import EndpointInfo, EndpointType, MessageOp
from bec_lib.messages import VariableMessage
def schedule(schedule_name: str) -> EndpointInfo:
"""
Endpoint for one named widget schedule (an ordered list of commands
plus their execution status). The whole schedule is stored as a single
`VariableMessage` document that gets overwritten and republished on
every change (`MessageOp.SET_PUBLISH`), so any other subscriber -
another instance of this widget, a monitoring script, ... - stays in
sync live.
Args:
schedule_name: a stable, user-chosen name for the schedule. Unlike
a widget's `gui_id` (regenerated every time the widget is
constructed), this name is what lets a widget that was closed
and reopened find its own previously persisted schedule again.
"""
return EndpointInfo(
endpoint=f"{EndpointType.USER.value}/schedule_widget/schedule/{schedule_name}",
message_type=VariableMessage,
message_op=MessageOp.SET_PUBLISH,
)
@@ -0,0 +1,11 @@
from __future__ import annotations
from enum import Enum
class ScheduleItemStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
ABORTED = "aborted"
@@ -0,0 +1,188 @@
"""
A small, self-contained utility for "if signal X drops below A, pause; once
it's back above B, resume" (hysteresis) behavior - e.g. auto-pausing scans
while the beam current is too low.
This module knows nothing about schedules, scans, or the rest of this
plugin. It only monitors one BEC device's live readback value
(`MessageEndpoints.device_readback`, the same endpoint the device server
publishes for every monitored device) and emits Qt signals when the value
crosses one of the two configured thresholds. `schedule_widget.py` is the
only place that connects those signals to schedule-specific behavior
(aborting/deferring a scan item) - see its module docstring for how.
"""
from __future__ import annotations
import threading
import time
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import BaseModel
from qtpy.QtCore import QObject, Signal
logger = bec_logger.logger
MIN_UPDATE_INTERVAL = 1
class GuardSettings(BaseModel):
"""Persisted configuration for one `SignalGuard`."""
enabled: bool = False
device_name: str | None = None
pause_below: float | None = None
resume_above: float | None = None
class SignalGuard(QObject):
"""
Monitors one device's readback value and reports hysteresis-based
pause/resume crossings.
- `paused` fires the first time the value drops below `pause_below`
(not again while it stays low).
- `resumed` fires the first time it then climbs back above
`resume_above` (not again while it stays high).
A value sitting between the two thresholds never re-triggers either
signal - that gap is the point of using two thresholds instead of one,
so a value oscillating right at a single cutoff wouldn't cause rapid
pause/resume flapping.
Callbacks from `RedisConnector.register()` run on a background
(Redis-listener) thread, not the Qt GUI thread; `paused`/`resumed` are
Qt signals, so connecting to them with the default (auto) connection
type safely marshals delivery onto whatever thread the receiver lives
on - no extra locking needed on the receiving end.
"""
value_update = Signal(float)
paused = Signal(float)
resumed = Signal(float)
def __init__(self, connector, parent=None, dev=None):
super().__init__(parent)
self.dev = dev
self._connector = connector
self._lock = threading.Lock()
self._clear_event = threading.Event()
self._clear_event.set() # not blocking until configured/proven otherwise
self.enabled = False
self.device_name: str | None = None
self.pause_below: float | None = None
self.resume_above: float | None = None
self.current_value: float | None = None
self.units: str = ""
self.prec: int = 3
self._subscribed_endpoint = None
self.last_val_update = time.time()
def configure(self, settings: GuardSettings):
"""(Re)configure and (re)subscribe. Safe to call repeatedly, e.g. after editing settings."""
self._unsubscribe()
self.enabled = settings.enabled
self.device_name = settings.device_name
self.pause_below = settings.pause_below
self.resume_above = settings.resume_above
self.current_value = None
self._clear_event.set()
if (
self.enabled
and self.device_name
and self.pause_below is not None
and self.resume_above is not None
):
self._subscribed_endpoint = MessageEndpoints.device_readback(self.device_name)
self._connector.register(topics=self._subscribed_endpoint, cb=self._on_readback)
description = self.dev[self.device_name].describe()[self.device_name]
self.units = description["units"]
self.prec = description["precision"]
def _unsubscribe(self):
if self._subscribed_endpoint is not None:
try:
self._connector.unregister(topics=self._subscribed_endpoint, cb=self._on_readback)
except Exception: # pylint: disable=broad-except
logger.exception(
f"Failed to unsubscribe SignalGuard from {self._subscribed_endpoint}"
)
self._subscribed_endpoint = None
def _on_readback(self, msg):
device_msg = getattr(msg, "value", None)
if device_msg is None:
return
value = _extract_value(device_msg, self.device_name)
if value is None:
return
crossed_pause = crossed_resume = False
with self._lock:
self.current_value = value
if self._clear_event.is_set():
if value < self.pause_below:
self._clear_event.clear()
crossed_pause = True
elif value > self.resume_above:
self._clear_event.set()
crossed_resume = True
if crossed_pause:
logger.info(
f"SignalGuard: {self.device_name} dropped to {value} (below {self.pause_below}) - pausing."
)
self.paused.emit(value)
elif crossed_resume:
logger.info(
f"SignalGuard: {self.device_name} recovered to {value} (above {self.resume_above}) - resuming."
)
self.resumed.emit(value)
current_time = time.time()
if current_time - self.last_val_update > MIN_UPDATE_INTERVAL:
self.last_val_update = current_time
self.value_update.emit(value)
def is_clear(self) -> bool:
"""True if not currently blocking (disabled, or value above the resume threshold)."""
return (not self.enabled) or self._clear_event.is_set()
def wait_until_clear(self, should_abort, poll_interval: float = 0.5) -> bool:
"""
Blocks the calling (non-GUI) thread until `is_clear()` becomes
True, checking `should_abort()` between polls so an operator abort
can still interrupt the wait. Returns True if it cleared, False if
`should_abort()` returned True first.
"""
if self.is_clear():
return True
while not should_abort():
if self._clear_event.wait(timeout=poll_interval):
return True
return False
def cleanup(self):
self._unsubscribe()
def _extract_value(device_msg, device_name: str | None):
"""
`DeviceMessage.signals` is keyed by signal name, e.g. {"beam_current":
{"value": ..., "timestamp": ...}, ...}. For a simple scalar device the
primary signal is usually named after the device itself; fall back to
the first signal found if not (e.g. a differently-named primary signal
on a compound device).
"""
signals = getattr(device_msg, "signals", None) or {}
if device_name in signals:
return signals[device_name].get("value")
for signal in signals.values():
if "value" in signal:
return signal["value"]
return None
@@ -0,0 +1,94 @@
"""Small settings dialog for `guard.GuardSettings` - kept separate from the guard logic itself."""
from __future__ import annotations
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QLabel,
QMessageBox,
QVBoxLayout,
)
from .guard import GuardSettings
_DSPIN_RANGE = (-1e12, 1e12)
class GuardSettingsDialog(QDialog):
"""Configure (or disable) the auto-pause/resume guard for scan items."""
def __init__(self, settings: GuardSettings, device_names: list[str], parent=None):
super().__init__(parent)
self.setWindowTitle("Auto-pause on signal")
layout = QVBoxLayout(self)
info_label = QLabel(
"If enabled, the currently running scan is aborted as soon as the chosen "
"signal drops below the pause value, and automatically restarted once it "
"rises back above the resume value. Only scan items are affected - device "
"moves and custom/RPC commands are never interrupted by this."
)
info_label.setWordWrap(True)
layout.addWidget(info_label)
form = QFormLayout()
layout.addLayout(form)
self.enabled_check = QCheckBox("Enable auto-pause")
self.enabled_check.setChecked(settings.enabled)
form.addRow("", self.enabled_check)
self.device_combo = QComboBox()
self.device_combo.addItems(device_names)
if settings.device_name:
idx = self.device_combo.findText(settings.device_name)
if idx >= 0:
self.device_combo.setCurrentIndex(idx)
form.addRow("Signal (device)", self.device_combo)
self.pause_below_spin = QDoubleSpinBox()
self.pause_below_spin.setDecimals(4)
self.pause_below_spin.setRange(*_DSPIN_RANGE)
if settings.pause_below is not None:
self.pause_below_spin.setValue(settings.pause_below)
form.addRow("Pause below", self.pause_below_spin)
self.resume_above_spin = QDoubleSpinBox()
self.resume_above_spin.setDecimals(4)
self.resume_above_spin.setRange(*_DSPIN_RANGE)
if settings.resume_above is not None:
self.resume_above_spin.setValue(settings.resume_above)
form.addRow("Resume above", self.resume_above_spin)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _on_accept(self):
if self.enabled_check.isChecked():
if not self.device_combo.currentText():
QMessageBox.warning(self, "Missing input", "Select a device to monitor.")
return
if self.resume_above_spin.value() <= self.pause_below_spin.value():
QMessageBox.warning(
self,
"Invalid thresholds",
"'Resume above' must be greater than 'Pause below' (hysteresis gap).",
)
return
self.accept()
def result_settings(self) -> GuardSettings:
"""Valid after `exec_()` returns `QDialog.Accepted`."""
return GuardSettings(
enabled=self.enabled_check.isChecked(),
device_name=self.device_combo.currentText() or None,
pause_below=self.pause_below_spin.value(),
resume_above=self.resume_above_spin.value(),
)
@@ -0,0 +1,629 @@
"""
The dialog behind the schedule widget's "Add..."/"Edit..." buttons.
"""
from __future__ import annotations
import math
from bec_lib.logger import bec_logger
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
BECDeviceFilter,
DeviceComboBox,
)
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl, ScanParameterConfig
# pylint: disable=E0611
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QScrollArea,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from .qt_widgets import MyButton
logger = bec_logger.logger
# Beamlines that get the XAS scan control, the Digital Twin item, and the
# beamline-specific quick-fill items (Absorber, Ionization Chamber,
# Reference foil changer, Auto-Gain).
_XAS_BEAMLINES = ("x01da", "x10da")
class ScheduleItemDialog(QDialog):
"""Add or edit one schedule item, via ScanControl, a move form, Digital Twin, or free text."""
def __init__(
self,
scans,
dev,
parent=None,
initial: dict | None = None,
client=None,
beamline: str | None = None,
):
super().__init__(parent)
self.setWindowTitle("Schedule item")
self.setMinimumSize(520, 480)
self._scans = scans
self._dev = dev
self._client = client
self.beamline = beamline
self._is_xas_beamline = self.beamline in _XAS_BEAMLINES
if self._is_xas_beamline:
logger.info(f"Loading bl-specific modules for beamline {self.beamline}")
from ....bec_ipython_client.plugins.digital_twin_core.digital_twin_core import (
DigitalTwinCore,
)
from ..auto_gain.auto_gain import AutoGain
from ..digital_twin.digital_twin import DigitalTwin
from ..edge_selector import EdgeSelector
from ..scan_control_xas.scan_control_xas import ScanControlXAS
self.DigitalTwinCore = DigitalTwinCore
self.DigitalTwin = DigitalTwin
self.AutoGain = AutoGain
self.EdgeSelector = EdgeSelector
self.ScanControlXAS = ScanControlXAS
layout = QVBoxLayout(self)
self.item_combo = QComboBox()
layout.addWidget(self.item_combo)
self.stack = QStackedWidget()
layout.addWidget(self.stack)
self._build_scan_page()
self._build_move_page()
if self._is_xas_beamline:
self._build_digital_twin_page()
self._build_abs_page()
self._build_ic_page()
self._build_reffoil_page()
self._build_auto_gain_page()
self._build_other_page()
self.item_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
# Shared across all items (not per-page), since it's a property
# of the schedule item, not of any one form.
self.pause_on_failure_check = QCheckBox("Pause schedule if item execution fails")
layout.addWidget(self.pause_on_failure_check)
self.item_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
# Dispatch table keyed by the dropdown item's data (see
# _add_page) - one place to register a new item type instead of
# scattering per-item checks through _collect_result().
self._collectors = {
"scan": self._collect_scan_result,
"move": self._collect_move_result,
"digital_twin": self._collect_digital_twin_result,
"abs": self._collect_abs_result,
"ic": self._collect_ic_result,
"reffoil": self._collect_reffoil_result,
"auto_gain": self._collect_auto_gain_result,
"other": self._collect_custom_result,
}
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Ok).setText("Add")
buttons.setStyleSheet("QPushButton {qproperty-icon: none;}")
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
# The embedded DigitalTwin instance runs a 1s polling timer and a
# bec_dispatcher subscription for its whole lifetime - stop both
# when this dialog closes, however it closes (OK, Cancel, or the
# window's own close button), not just on accept.
self.finished.connect(self._cleanup_digital_twin)
self._apply_initial(initial or {})
# ------------------------------------------------------------------ #
# Dropdown/page plumbing
# ------------------------------------------------------------------ #
def _add_page(self, key: str, label: str, widget: QWidget):
# The combo and the stack are always appended to together, so a
# combo index doubles as a stack index - no separate key->index
# map needed. `key` is what _collect_result()/_apply_initial()
# dispatch on.
self.item_combo.addItem(label, key)
self.stack.addWidget(widget)
def _select_page(self, key: str) -> bool:
"""Switch the dropdown to `key`'s page; False if that item isn't available."""
idx = self.item_combo.findData(key)
if idx < 0:
return False
self.item_combo.setCurrentIndex(idx)
return True
# ------------------------------------------------------------------ #
# ScanControl - embeds BEC's own ScanControl widget
# ------------------------------------------------------------------ #
def _build_scan_page(self):
page = QWidget()
layout = QVBoxLayout(page)
# client=None resolves to the same process-wide BEC client
# (bec_dispatcher.client) our own widget uses - no second Redis
# connection is opened.
if self._is_xas_beamline:
self.scan_control = self.ScanControlXAS(parent=page, client=self._client)
else:
self.scan_control = ScanControl(parent=page, client=self._client)
self.scan_control.button_run_scan.hide()
layout.addWidget(self.scan_control)
self._add_page("scan", "ScanControl", page)
def _collect_scan_result(self) -> dict:
# Same call ScanControl.run_scan() makes before actually
# submitting, to resolve a typed-but-unconfirmed scan name.
self.scan_control.validate_scan_selection()
scan_name = self.scan_control.current_scan
if not scan_name:
raise ValueError("No scan selected.")
# bec_object=False: plain, repr-able values (e.g. a device name
# string rather than the live DeviceBase instance) - needed since
# the result has to survive a round-trip through Redis as text and
# be re-evaluated later, not just used in-process immediately.
args, kwargs = self.scan_control.get_scan_parameters(bec_object=False)
command = _format_scan_call(scan_name, args, kwargs)
return {
"command": command,
"kind": "scan",
"form_state": {"scan_name": scan_name, "args": args, "kwargs": kwargs},
}
def _prefill_scan_page(self, scan_name: str, args: list, kwargs: dict):
# ScanControl restores parameters for a scan from its own config
# cache (see `ScanControl.restore_scan_parameters`); pre-loading
# that cache before switching to the scan reuses that mechanism
# instead of poking at its internal argument widgets directly.
self.scan_control.config.scans[scan_name] = ScanParameterConfig(
name=scan_name, args=args, kwargs=kwargs
)
self.scan_control.current_scan = scan_name
self.scan_control.restore_scan_parameters(scan_name)
# ------------------------------------------------------------------ #
# Move
# ------------------------------------------------------------------ #
def _build_move_page(self):
page = QWidget()
form = QFormLayout(page)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
self.move_device_combo = DeviceComboBox(self, device_filter=[BECDeviceFilter.POSITIONER])
form.addRow("Device", self.move_device_combo)
self.move_value_spin = QDoubleSpinBox()
form.addRow("Value", self.move_value_spin)
self.move_relative_check = QCheckBox("")
form.addRow("Relative move", self.move_relative_check)
self.move_device_combo.currentIndexChanged.connect(self._adjust_spinbox)
self._add_page("move", "Move", page)
def _adjust_spinbox(self, _) -> None:
device = self._dev[self.move_device_combo.currentText()]
prec = device.precision
units = device.egu()
ll = device.low_limit
hl = device.high_limit
self.move_value_spin.setDecimals(prec)
self.move_value_spin.setSuffix(f" {units}")
if (hl - ll) > 0:
self.move_value_spin.setMinimum(ll)
self.move_value_spin.setMaximum(hl)
self.move_value_spin.setSingleStep(10 ** round(math.log10((hl - ll) / 100)))
else:
self.move_value_spin.setMinimum(1e6)
self.move_value_spin.setMaximum(-1e6)
self.move_value_spin.setSingleStep(1)
def _collect_move_result(self) -> dict:
device_name = self.move_device_combo.currentText()
if not device_name:
raise ValueError("No movable device available/selected.")
value = self.move_value_spin.value()
units = self.move_value_spin.suffix()
relative = self.move_relative_check.isChecked()
return {
"command": f"dev.{device_name}.move({value!r}, relative={relative!r})",
"kind": "move",
"form_state": {
"device_name": device_name,
"value": value,
"units": units,
"relative": relative,
},
}
def _prefill_move_page(self, state: dict):
idx = self.move_device_combo.findText(state.get("device_name", ""))
if idx >= 0:
self.move_device_combo.setCurrentIndex(idx)
self.move_value_spin.setValue(float(state.get("value", 0.0)))
self.move_relative_check.setChecked(bool(state.get("relative", False)))
# ------------------------------------------------------------------ #
# Digital Twin - embeds the beamline-alignment DigitalTwin widget
# ------------------------------------------------------------------ #
def _build_digital_twin_page(self):
page = QWidget()
outer = QVBoxLayout(page)
# DigitalTwin defaults to a large fixed size (it's normally its
# own top-level window) - wrap it in a scroll area so this dialog
# doesn't have to grow to match it.
scroll = QScrollArea()
scroll.setWidgetResizable(True)
self.digital_twin = self.DigitalTwin(parent=scroll, client=self._client)
# Hide move and abs open buttons
for mover in self.digital_twin.mover.mover_widgets:
mover.btn_action.hide()
self.digital_twin.mover.abs.btn_action.hide()
scroll.setWidget(self.digital_twin)
outer.addWidget(scroll)
hint = QLabel(
"Configure the beamline alignment above, then confirm with Add below - the "
"computed motor targets are captured now and moved together (in one combined "
"move) when this schedule item runs, not immediately."
)
hint.setWordWrap(True)
hint.setStyleSheet("color: gray;")
outer.addWidget(hint)
self._add_page("digital_twin", "Digital Twin", page)
def _collect_digital_twin_result(self) -> dict:
# A frozen snapshot of the configured targets, not a live handle -
# editing the Digital Twin widget afterwards must not affect an
# already-added schedule item (see module docstring).
config = self.digital_twin.get_assistant_config()
cmd = f"digital_twin.move_with_config({config})"
return {
"command": cmd,
"kind": "custom",
"form_state": {"text": cmd, "source": "digital_twin", "config": config},
}
def _cleanup_digital_twin(self, *_):
digital_twin = getattr(self, "digital_twin", None)
if digital_twin is None:
return
try:
digital_twin._timer.stop() # pylint: disable=protected-access
except Exception: # pylint: disable=broad-except
logger.exception("Failed to stop the Digital Twin's reality-update timer.")
try:
digital_twin.cleanup()
except Exception: # pylint: disable=broad-except
logger.exception("Failed to clean up the embedded Digital Twin widget.")
def _prefill_digital_twin_page(self, state: dict):
config = state["config"]
self.digital_twin.set_assistant_config(config)
# ------------------------------------------------------------------ #
# Absorber
# ------------------------------------------------------------------ #
def _build_abs_page(self):
if "abs" not in self._dev:
return
page = QWidget()
form = QFormLayout(page)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
self.abs_selector = QComboBox()
self.abs_selector.addItems(["Open", "Force open", "Close"])
form.addRow("Action", self.abs_selector)
self._add_page("abs", "Absorber", page)
def _collect_abs_result(self) -> dict:
action = self.abs_selector.currentText()
suffix = {"Open": "open()", "Force open": "open(force=True)", "Close": "close()"}.get(
action
)
if suffix is None:
raise ValueError("Select an absorber action.")
cmd = f"dev.abs.{suffix}"
return {
"command": cmd,
"kind": "custom",
"form_state": {"text": cmd, "source": "abs", "action": action},
}
def _prefill_abs_form(self, state: dict):
idx = self.abs_selector.findText(state.get("action", ""))
if idx >= 0:
self.abs_selector.setCurrentIndex(idx)
# ------------------------------------------------------------------ #
# Ionization chamber
# ------------------------------------------------------------------ #
def _build_ic_page(self):
if not all(key in self._dev for key in ("ic0", "ic1", "ic2")):
return
page = QWidget()
form = QFormLayout(page)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
self.ic_selector = QComboBox()
self.ic_selector.addItems(["IC0", "IC1", "IC2"])
gases = ["He", "N2", "Ar", "Kr"]
self.gas1 = QComboBox()
self.gas2 = QComboBox()
self.gas1.addItems(gases)
self.gas2.addItems(gases)
self.conc1 = QDoubleSpinBox()
self.conc2 = QDoubleSpinBox()
for conc in (self.conc1, self.conc2):
conc.setDecimals(0)
conc.setSuffix(" %")
conc.setMinimum(0)
conc.setMaximum(100)
conc.setSingleStep(1)
self.pressure = QDoubleSpinBox()
self.pressure.setDecimals(3)
self.pressure.setSuffix(" bar abs")
self.pressure.setMinimum(1)
self.pressure.setMaximum(3)
self.pressure.setSingleStep(0.1)
form.addRow("Ionization chamber", self.ic_selector)
form.addRow("Gas 1", self.gas1)
form.addRow("Concentration 1", self.conc1)
form.addRow("Gas 2", self.gas2)
form.addRow("Concentration 2", self.conc2)
form.addRow("Pressure", self.pressure)
self.conc1.valueChanged.connect(self._equalize_ic_conc)
self.conc2.valueChanged.connect(self._equalize_ic_conc)
self._add_page("ic", "Ionization Chamber", page)
def _equalize_ic_conc(self, new_val):
if self.conc1.value() == new_val: # conc1 was changed
self.conc2.setValue(100 - new_val)
else:
self.conc1.setValue(100 - new_val)
def _collect_ic_result(self) -> dict:
total = self.conc1.value() + self.conc2.value()
if total != 100:
raise ValueError(f"Gas concentrations must add up to 100% (currently {total:.0f}%).")
chamber = self.ic_selector.currentText()
ic = {"IC0": "ic0", "IC1": "ic1", "IC2": "ic2"}[chamber]
cmd = (
f"dev.{ic}.fill("
f"gas1='{self.gas1.currentText()}', conc1={self.conc1.value()}, "
f"gas2='{self.gas2.currentText()}', conc2={self.conc2.value()}, "
f"pressure={self.pressure.value()}, wait=True)"
)
return {
"command": cmd,
"kind": "custom",
"form_state": {
"text": cmd,
"source": "ic",
"chamber": chamber,
"gas1": self.gas1.currentText(),
"conc1": self.conc1.value(),
"gas2": self.gas2.currentText(),
"conc2": self.conc2.value(),
"pressure": self.pressure.value(),
},
}
def _prefill_ic_form(self, state: dict):
idx = self.ic_selector.findText(state.get("chamber", ""))
if idx >= 0:
self.ic_selector.setCurrentIndex(idx)
idx = self.gas1.findText(state.get("gas1", ""))
if idx >= 0:
self.gas1.setCurrentIndex(idx)
idx = self.gas2.findText(state.get("gas2", ""))
if idx >= 0:
self.gas2.setCurrentIndex(idx)
# Set conc2 last: conc1's valueChanged->_equalize_ic_conc would
# otherwise immediately overwrite it.
self.conc1.setValue(float(state.get("conc1", 0)))
self.conc2.setValue(float(state.get("conc2", 0)))
self.pressure.setValue(float(state.get("pressure", self.pressure.minimum())))
# ------------------------------------------------------------------ #
# Reference foil changer
# ------------------------------------------------------------------ #
def _build_reffoil_page(self):
if "reffoilchanger" not in self._dev:
return
page = QWidget()
form = QFormLayout(page)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
self.reffoil_selector = QComboBox()
self.reffoil_selector.addItems(self._dev.reffoilchanger.get_all_foils())
form.addRow("Reference foil", self.reffoil_selector)
self._add_page("reffoil", "Reference foil changer", page)
def _collect_reffoil_result(self) -> dict:
foil = self.reffoil_selector.currentText()
if not foil:
raise ValueError("Select a reference foil.")
cmd = f"dev.reffoilchanger.insert(ref='{foil}', wait=True)"
return {
"command": cmd,
"kind": "custom",
"form_state": {"text": cmd, "source": "reffoil", "foil": foil},
}
def _prefill_reffoil_form(self, state: dict):
idx = self.reffoil_selector.findText(state.get("foil", ""))
if idx >= 0:
self.reffoil_selector.setCurrentIndex(idx)
# ------------------------------------------------------------------ #
# Auto-Gain
# ------------------------------------------------------------------ #
def _build_auto_gain_page(self):
page = QWidget()
outer = QVBoxLayout(page)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
self.auto_gain = self.AutoGain(show_control=False, parent=scroll, client=self._client)
scroll.setWidget(self.auto_gain)
outer.addWidget(scroll)
hint = QLabel(
"Configure the auto-gain functionality, then confirm with Add below - the "
"auto-gain will only be executed when this schedule item runs, not immediately."
)
hint.setWordWrap(True)
hint.setStyleSheet("color: gray;")
outer.addWidget(hint)
self._add_page("auto_gain", "Auto Gain", page)
def _collect_auto_gain_result(self) -> dict:
config = self.auto_gain.get_config()
cmd = (
f"auto_gain.start(element='{config['element']}', edge='{config['edge']}', "
+ f"amplifier={config['all_amplifiers']}, link_ic0_ic1={config['link_ic0_ic1']}, "
+ f"comp_ring_current= {config['compensate_ring_current']}, "
+ f"prohibited_gains={config['prohibited_gains']})"
)
return {
"command": cmd,
"kind": "custom",
"form_state": {"text": cmd, "source": "auto_gain", "config": config},
}
def _prefill_auto_gain_form(self, state: dict):
self.auto_gain.load_config(state["config"])
# ------------------------------------------------------------------ #
# Other
# ------------------------------------------------------------------ #
def _build_other_page(self):
page = QWidget()
layout = QVBoxLayout(page)
layout.addWidget(
QLabel(
"Free-form command, evaluated against `scans` and `dev` - use this for "
"anything the other items don't cover, e.g. an RPC call to another widget."
)
)
self.custom_edit = QLineEdit()
# TODO Change to a different placeholder text
self.custom_edit.setPlaceholderText("scans.xas_simple_scan(12000, 14000, 2, 10)")
layout.addWidget(self.custom_edit)
layout.addStretch(1)
self._add_page("other", "Other", page)
def _collect_custom_result(self) -> dict:
text = self.custom_edit.text().strip()
if not text:
raise ValueError("Command must not be empty.")
return {"command": text, "kind": "custom", "form_state": {"text": text}}
# ------------------------------------------------------------------ #
# pre-fill (edit mode) / result extraction
# ------------------------------------------------------------------ #
def _apply_initial(self, initial: dict):
self.pause_on_failure_check.setChecked(bool(initial.get("pause_on_failure", False)))
logger.info(f"Got initial: {initial}")
kind = initial.get("kind")
state = initial.get("form_state") or {}
source = state.get("source")
if kind == "scan" and state.get("scan_name"):
self._prefill_scan_page(
state["scan_name"], state.get("args") or [], state.get("kwargs") or {}
)
self._select_page("scan")
elif kind == "custom" and source == "digital_twin" and self._select_page("digital_twin"):
self._prefill_digital_twin_page(state)
elif kind == "move" and state.get("device_name"):
self._prefill_move_page(state)
self._select_page("move")
elif kind == "custom" and source == "abs" and self._select_page("abs"):
self._prefill_abs_form(state)
elif kind == "custom" and source == "ic" and self._select_page("ic"):
self._prefill_ic_form(state)
elif kind == "custom" and source == "reffoil" and self._select_page("reffoil"):
self._prefill_reffoil_form(state)
elif kind == "custom" and source == "auto_gain" and self._select_page("auto_gain"):
self._prefill_auto_gain_form(state)
elif initial.get("command"):
# "custom" kind with no (recognized/available) source, or a
# legacy/unrecognized item - fall back to showing the raw
# command text as-is.
self.custom_edit.setText(state.get("text", initial["command"]))
self._select_page("other")
else:
logger.warning(f"Unknown kind: {kind}")
def _on_accept(self):
try:
result = self._collect_result()
except ValueError as exc:
QMessageBox.warning(self, "Can't build command", str(exc))
return
self._result = result
self.accept()
def _collect_result(self) -> dict:
key = self.item_combo.currentData()
handler = self._collectors.get(key)
if handler is None:
raise ValueError(f"Unknown item type: {key!r}")
result = handler()
result["pause_on_failure"] = self.pause_on_failure_check.isChecked()
return result
def result(self) -> dict:
"""Valid after `exec_()` returns `QDialog.Accepted`."""
return self._result
def _format_scan_call(scan_name: str, args: list, kwargs: dict) -> str:
parts = [repr(a) for a in args]
parts += [f"{name}={value!r}" for name, value in kwargs.items()]
return f"scans.{scan_name}({', '.join(parts)})"
@@ -0,0 +1,231 @@
"""
Settings dialog for `notifications.NotificationSettings` - kept separate
from the send logic itself (`notifications.py`).
"""
from __future__ import annotations
from bec_lib.logger import bec_logger
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QGroupBox,
QLabel,
QMessageBox,
QPushButton,
QVBoxLayout,
)
from .enums import ScheduleItemStatus
from .notifications import NotificationSettings, NotificationTarget, send_notification
logger = bec_logger.logger
def _service_state(client, name: str) -> tuple[bool, list[str]]:
"""
Best-effort read of a messaging service's enabled/scopes state (see
the module docstring in notifications.py for why this is the only
introspection available). Never raises - a client without `messaging`
set up yet just looks like "not enabled, no scopes".
"""
messaging = getattr(client, "messaging", None)
service = getattr(messaging, name, None) if messaging is not None else None
if service is None:
return False, []
return bool(getattr(service, "_enabled", False)), sorted(getattr(service, "_scopes", set()))
class NotificationSettingsDialog(QDialog):
"""Choose whether/where/for-which-item-types finish/fail notifications are sent."""
def __init__(self, settings: NotificationSettings, client, parent=None):
super().__init__(parent)
self._client = client
self.setWindowTitle("Notifications")
self.setMinimumWidth(380)
layout = QVBoxLayout(self)
info_label = QLabel(
"Sends a message through the selected BEC messaging service whenever a "
"schedule item finishes or fails."
)
info_label.setWordWrap(True)
layout.addWidget(info_label)
self.enabled_check = QCheckBox("Send notifications")
self.enabled_check.setChecked(settings.enabled)
layout.addWidget(self.enabled_check)
kind_box = QGroupBox("Notify for")
kind_layout = QVBoxLayout(kind_box)
self.scan_check = QCheckBox("Scans")
self.scan_check.setChecked(settings.notify_scan)
self.move_check = QCheckBox("Movements")
self.move_check.setChecked(settings.notify_move)
self.rpc_check = QCheckBox("RPC / custom commands")
self.rpc_check.setChecked(settings.notify_rpc)
for check in (self.scan_check, self.move_check, self.rpc_check):
kind_layout.addWidget(check)
layout.addWidget(kind_box)
target_box = QGroupBox("Send via")
target_layout = QVBoxLayout(target_box)
self.scilog_check = QCheckBox("SciLog")
self.teams_check = QCheckBox("Microsoft Teams")
self.signal_check = QCheckBox("Signal Messenger")
for check in (self.scilog_check, self.teams_check, self.signal_check):
target_layout.addWidget(check)
layout.addWidget(target_box)
event_box = QGroupBox("Send on")
event_layout = QVBoxLayout(event_box)
self.completed_check = QCheckBox("Completed")
self.aborted_check = QCheckBox("Aborted")
self.failed_check = QCheckBox("Failed")
for check in (self.completed_check, self.aborted_check, self.failed_check):
event_layout.addWidget(check)
layout.addWidget(event_box)
# Teams: contact/channel can only be a scope an admin has already
# registered for the deployment - there's no way to address an
# arbitrary Teams user from the client, so this is a dropdown, not
# free text (see notifications.py's module docstring).
self.teams_box = QGroupBox("Teams settings")
teams_layout = QVBoxLayout(self.teams_box)
teams_enabled, teams_scopes = _service_state(client, "teams")
self.teams_combo = QComboBox()
self.teams_combo.addItems(teams_scopes)
self.teams_combo.setEnabled(teams_enabled)
teams_layout.addWidget(QLabel("Contact or channel"))
teams_layout.addWidget(self.teams_combo)
if not teams_enabled:
teams_layout.addWidget(_dim_label("Teams messaging is not enabled for this session."))
elif not teams_scopes:
teams_layout.addWidget(_dim_label("No Teams contacts/channels are registered."))
layout.addWidget(self.teams_box)
# Signal: a raw phone number works directly (BEC normalizes it -
# see the docs), so this is an editable combo box: type a number,
# or pick a pre-registered scope if any exist.
self.signal_box = QGroupBox("Signal settings")
signal_layout = QVBoxLayout(self.signal_box)
signal_enabled, signal_scopes = _service_state(client, "signal")
self.signal_combo = QComboBox()
self.signal_combo.setEditable(True)
self.signal_combo.addItems(signal_scopes)
self.signal_combo.setEnabled(signal_enabled)
signal_layout.addWidget(QLabel("Phone number or contact"))
self.signal_combo.setEditText("")
self.signal_combo.lineEdit().setPlaceholderText("e.g. +41791234567")
signal_layout.addWidget(self.signal_combo)
if not signal_enabled:
signal_layout.addWidget(_dim_label("Signal messaging is not enabled for this session."))
layout.addWidget(self.signal_box)
# pre-fill from the persisted settings
{
NotificationTarget.SCILOG: self.scilog_check,
NotificationTarget.TEAMS: self.teams_check,
NotificationTarget.SIGNAL: self.signal_check,
}.get(settings.target, self.scilog_check).setChecked(True)
for event in settings.events:
{
ScheduleItemStatus.COMPLETED: self.completed_check,
ScheduleItemStatus.ABORTED: self.aborted_check,
ScheduleItemStatus.FAILED: self.failed_check,
}.get(event).setChecked(True)
if settings.teams_scope:
idx = self.teams_combo.findText(settings.teams_scope)
if idx >= 0:
self.teams_combo.setCurrentIndex(idx)
if settings.signal_number:
self.signal_combo.setEditText(settings.signal_number)
self.scilog_check.toggled.connect(lambda *_: self._sync_target_widgets(self.scilog_check))
self.teams_check.toggled.connect(lambda *_: self._sync_target_widgets(self.teams_check))
self.signal_check.toggled.connect(lambda *_: self._sync_target_widgets(self.signal_check))
self.test_btn = QPushButton("Send test message")
self.test_btn.clicked.connect(self._on_test_clicked)
layout.addWidget(self.test_btn)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
self._sync_target_widgets()
def _sync_target_widgets(self, current_button: QCheckBox = None):
if current_button is None:
for button in [self.scilog_check, self.teams_check, self.signal_check]:
if button.isChecked():
current_button = button
break
if current_button.isChecked():
for button in [self.scilog_check, self.teams_check, self.signal_check]:
if button != current_button:
button.setChecked(False)
if current_button == self.teams_check:
self.teams_box.setVisible(True)
self.signal_box.setVisible(False)
elif current_button == self.signal_check:
self.teams_box.setVisible(False)
self.signal_box.setVisible(True)
else:
self.teams_box.setVisible(False)
self.signal_box.setVisible(False)
self.layout().activate()
self.adjustSize()
def _current_settings(self) -> NotificationSettings:
if self.scilog_check.isChecked():
target = NotificationTarget.SCILOG
elif self.teams_check.isChecked():
target = NotificationTarget.TEAMS
elif self.signal_check.isChecked():
target = NotificationTarget.SIGNAL
else:
raise ValueError(f"Target {target} not supported")
events = []
if self.completed_check.isChecked():
events.append(ScheduleItemStatus.COMPLETED)
if self.aborted_check.isChecked():
events.append(ScheduleItemStatus.ABORTED)
if self.failed_check.isChecked():
events.append(ScheduleItemStatus.FAILED)
return NotificationSettings(
enabled=self.enabled_check.isChecked(),
notify_scan=self.scan_check.isChecked(),
notify_move=self.move_check.isChecked(),
notify_rpc=self.rpc_check.isChecked(),
target=target,
events=events,
teams_scope=self.teams_combo.currentText().strip() or None,
signal_number=self.signal_combo.currentText().strip() or None,
)
def _on_test_clicked(self):
settings = self._current_settings()
try:
send_notification(self._client, settings, "Test message from the BEC schedule widget.")
except Exception as exc: # pylint: disable=broad-except
QMessageBox.critical(self, "Test message failed", str(exc))
return
QMessageBox.information(self, "Test message sent", "The test message was sent.")
def result_settings(self) -> NotificationSettings:
"""Valid after `exec_()` returns `QDialog.Accepted`."""
return self._current_settings()
def _dim_label(text: str) -> QLabel:
label = QLabel(text)
label.setStyleSheet("color: gray;")
label.setWordWrap(True)
return label
@@ -0,0 +1,159 @@
"""
A small, self-contained "tell the user when something finished/failed"
feature, independent of scheduling/execution logic.
Sends through BEC's own `client.messaging` container
(`bec_lib.messaging_services.MessagingContainer`, exposed as `bec.messaging`
in the IPython client) - the same mechanism documented at
https://bec.readthedocs.io/latest/how-to/general/send-messages-to-signal.html
and
https://bec.readthedocs.io/latest/how-to/general/send-messages-to-scilog.html:
bec.messaging.signal.new("Beamline checks completed.").send(scope="+41791234567")
bec.messaging.scilog.new("Beamline checks completed.").send()
bec.messaging.teams.new("Beamline checks completed.").send(scope=<registered scope>)
Notes on each service, read directly from `bec_lib.messaging_services`
rather than assumed:
- **SciLog** needs no `scope` - it posts to the logbook of the currently
active pgroup automatically.
- **Signal** accepts a raw phone number as `scope` (BEC normalizes it,
defaulting to the Swiss country code for numbers without one - see the
docs above) *or* a pre-registered scope name for a group.
- **Teams** is a plain `MessagingService` with no Teams-specific methods:
a contact/channel can only be addressed by a `scope` an admin has
already registered for the deployment (`service._scopes`) - there is no
way to address an arbitrary Teams user/email directly from the client.
This is why the Teams UI in `notification_dialog.py` is a dropdown of
known scopes rather than free text, while Signal's is free text (with
any registered scopes offered as suggestions).
`client.messaging.<service>._enabled` / `._scopes` are the only
introspection available (no public accessor exists at the time of
writing) - used here and in the dialog to grey out a target that isn't
configured for the current session instead of only failing at send time.
"""
from __future__ import annotations
from enum import Enum
from bec_lib.logger import bec_logger
from pydantic import BaseModel
from .enums import ScheduleItemStatus
logger = bec_logger.logger
class NotificationTarget(str, Enum):
SCILOG = "scilog"
TEAMS = "teams"
SIGNAL = "signal"
class NotificationSettings(BaseModel):
"""Persisted per-schedule notification preferences."""
enabled: bool = False
notify_scan: bool = True
notify_move: bool = True
# "custom" is what this plugin calls the free-text tab (see
# item_dialog.py) - it covers RPC calls to other widgets as well as
# any hand-typed command, so it's exposed to the user as "RPC".
notify_rpc: bool = True
target: NotificationTarget = NotificationTarget.SCILOG
events: list[ScheduleItemStatus] = []
teams_scope: str | None = None # a pre-registered Teams scope (channel/contact)
signal_number: str | None = None # a phone number, or a pre-registered Signal scope
def _enabled_for_kind(settings: NotificationSettings, kind: str) -> bool:
if not settings.enabled:
return False
return {
"scan": settings.notify_scan,
"move": settings.notify_move,
"custom": settings.notify_rpc,
}.get(kind, False)
def send_notification(client, settings: NotificationSettings, text: str) -> None:
"""
Send `text` through whichever service `settings.target` selects.
Raises on failure (no target selected, missing Teams scope/Signal
number, the target service not enabled for this session, ...) -
callers that want a fire-and-forget send should catch around this.
`notify_item_finished` does; the dialog's "Send test message" button
deliberately does not, so the operator sees exactly what went wrong.
"""
if settings.target == NotificationTarget.SCILOG:
client.messaging.scilog.new(text).send()
elif settings.target == NotificationTarget.TEAMS:
if not settings.teams_scope:
raise ValueError("No Teams contact/channel selected.")
client.messaging.teams.new(text).send(scope=settings.teams_scope)
elif settings.target == NotificationTarget.SIGNAL:
if not settings.signal_number:
raise ValueError("No Signal phone number/contact set.")
client.messaging.signal.new(text).send(scope=settings.signal_number)
else:
raise ValueError("No notification target selected.")
def notify_item_finished(
client,
kind: str,
command: str,
scan_number: str,
final_status: ScheduleItemStatus,
settings: NotificationSettings,
error: str | None = None,
):
"""
Sends a message if notifications are enabled for `kind` and a target
is configured. Never raises - a notification failing to send should
not affect schedule execution.
"""
if not _enabled_for_kind(settings, kind):
return
if final_status not in settings.events:
return
status_word = final_status.name
text = f"**BEC Scheduler Widget**\nStatus: {kind} {status_word}"
if scan_number is not None:
text = text + f"\nScan number: {scan_number}"
text = text + f"\n\nCommand: {command}"
if error:
text += f"\n\n{error.strip().splitlines()[-1]}"
try:
send_notification(client, settings, text)
except Exception: # pylint: disable=broad-except
logger.exception(f"Failed to send schedule notification via {settings.target.value}")
def notify_schedule_state(client, settings, schedule_name, state):
"""
Sends a message if notifications are enabled and a schedule is started or stopped.
Never raises - a notification failing to send should not affect schedule execution.
"""
text = f'**BEC Scheduler Widget**\nSchedule "{schedule_name}" '
if state == "started":
text = text + "has started"
elif state == "aborted":
text = text + "was aborted"
elif state == "widget_closed":
text = text + "has finished prematurely because widget was closed"
elif state == "finished":
text = text + "has finished"
else:
logger.warning(f"Unknown schedule state {state}")
try:
send_notification(client, settings, text)
except Exception: # pylint: disable=broad-except
logger.exception(f"Failed to send schedule notification via {settings.target.value}")
@@ -0,0 +1,159 @@
from bec_widgets.utils.colors import get_accent_colors
from qtpy.QtCore import QModelIndex, QSize, Qt, Signal
# pylint: disable=E0611
from qtpy.QtGui import QKeySequence
from qtpy.QtWidgets import QHeaderView, QPushButton, QStyle, QStyledItemDelegate, QTableView
_ROW_MARGIN = 2 # total vertical padding (top+bottom) added around the text block
class _MultilineTextDelegate(QStyledItemDelegate):
"""
Paints and sizes a cell whose DisplayRole text may contain literal
'\\n' line breaks (schedule item labels are built with explicit line
breaks, e.g. the digital-twin config summary). The base
QStyledItemDelegate only reliably shows a single line, so without this
a multi-line label got clipped once rows stopped auto-sizing the way
QListWidgetItem used to. `sizeHint()` reports the full multi-line
height so the view's ResizeToContents row mode can actually fit it.
Selection is painted here with `option.palette.highlight()` - the
same approach BecLogTableView's `_LogCellDelegate` uses - so the row
gets the theme's native (blue) highlight color instead of whatever a
stylesheet might otherwise hardcode.
"""
def paint(self, painter, option, index):
painter.save()
if option.state & QStyle.StateFlag.State_Selected:
painter.fillRect(option.rect, option.palette.highlight())
painter.setPen(option.palette.highlightedText().color())
else:
painter.setPen(option.palette.text().color())
painter.setFont(option.font)
text = index.data(Qt.ItemDataRole.DisplayRole) or ""
rect = option.rect.adjusted(4, _ROW_MARGIN // 2, -4, -(_ROW_MARGIN // 2))
# painter.drawText(rect, int(Qt.TextFlag.TextWordWrap), text)
painter.drawText(
rect,
int(
Qt.TextFlag.TextWordWrap
| Qt.AlignmentFlag.AlignLeft
| Qt.AlignmentFlag.AlignVCenter
),
text,
)
painter.restore()
def sizeHint(self, option, index):
text = index.data(Qt.ItemDataRole.DisplayRole) or ""
lines = text.count("\n") + 1
height = option.fontMetrics.lineSpacing() * lines + _ROW_MARGIN
width = super().sizeHint(option, index).width()
return QSize(width, height)
class MyTableView(QTableView):
deletePressed = Signal()
emptySpaceClicked = Signal()
copyPressed = Signal()
pastePressed = Signal()
# Column layout is fixed: 0 = status icon, 1 = text (stretches), 2 = pause-on-failure icon.
TEXT_COLUMN = 1
def __init__(self, parent=None, status_col_width: int = 60, pause_col_width: int = 130):
super().__init__(parent)
self._status_col_width = status_col_width
self._pause_col_width = pause_col_width
self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
self.setSelectionMode(QTableView.SelectionMode.SingleSelection)
self.setEditTriggers(QTableView.EditTrigger.NoEditTriggers)
self.setShowGrid(False)
self.setWordWrap(True)
self.setItemDelegateForColumn(self.TEXT_COLUMN, _MultilineTextDelegate(self))
self.verticalHeader().hide()
# ResizeToContents (not Fixed) so a row grows to fit however many
# lines its text cell reports via the delegate's sizeHint above -
# this is what lets multi-line item text show in full again.
self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
# No `:selected` rule here on purpose - leaving it out lets Qt fall
# back to the palette highlight color (blue) for selected rows,
# matching BecLogTableView instead of a hardcoded dark gray.
# self.setStyleSheet("""
# QTableView::item {
# border-bottom: 1px solid #444444;
# padding: 4px;
# }
# """)
def configure_columns(self):
header = self.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
header.resizeSection(0, self._status_col_width)
header.resizeSection(2, self._pause_col_width)
header.setStretchLastSection(False)
header.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
def keyPressEvent(self, event):
if event.key() == Qt.Key_Delete and self.currentIndex().isValid():
self.deletePressed.emit()
event.accept()
return
if event.matches(QKeySequence.StandardKey.Copy) and self.currentIndex().isValid():
self.copyPressed.emit()
event.accept()
return
if event.matches(QKeySequence.StandardKey.Paste):
self.pastePressed.emit()
event.accept()
return
super().keyPressEvent(event)
def mousePressEvent(self, event):
if (
event.button() == Qt.LeftButton
and not self.indexAt(event.position().toPoint()).isValid()
):
self.clearSelection()
self.setCurrentIndex(QModelIndex())
self.emptySpaceClicked.emit()
return
super().mousePressEvent(event)
class MyButton(QPushButton):
def __init__(self, text="", color="default", parent=None):
self.color = color
super().__init__(text, parent)
self.apply_theme()
def apply_theme(self):
if self.isEnabled():
colors = get_accent_colors()
color = getattr(colors, self.color).name()
self.setStyleSheet(f"QPushButton {{ background-color: {color}; color: white; }}")
else:
self.setStyleSheet(
"QPushButton {{background-color: rgb(120, 120, 120); color: white;}}"
)
def setEnabled(self, enable: bool = True):
super().setEnabled(enable)
self.apply_theme()
@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from .scheduler_plugin import SchedulerPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(SchedulerPlugin())
if __name__ == "__main__": # pragma: no cover
main()
@@ -0,0 +1,70 @@
"""
Plain (non-BECMessage) data model for one schedule entry and the schedule
as a whole.
These are ordinary pydantic models used only client-side, for validation
and convenience. They are never sent over Redis as their own type - only
ever as the `.value` payload of a `bec_lib.messages.VariableMessage` (see
`endpoints.schedule` for why).
"""
from __future__ import annotations
from enum import Enum
from typing import Literal
from pydantic import BaseModel, Field
from .enums import ScheduleItemStatus
from .guard import GuardSettings
from .notifications import NotificationSettings
# Which tab of the Add/Edit dialog built `command`, and therefore how to
# re-open that dialog pre-filled with the same values for editing. "custom"
# covers hand-typed text (including RPC calls to other widgets), which has
# no structured `form_state` to restore beyond the raw text itself.
ScheduleItemKind = Literal["scan", "move", "custom"]
class ScheduleItem(BaseModel):
"""A single command in the schedule, plus its execution bookkeeping."""
item_id: str
command: str
status: ScheduleItemStatus = ScheduleItemStatus.PENDING
# How `command` was produced, and (for "scan"/"move") the structured
# inputs behind it, so the Add/Edit dialog can be reopened pre-filled
# instead of asking the user to re-type everything. `command` itself
# remains the single source of truth actually evaluated at execution
# time - `kind`/`form_state` only drive the UI.
kind: ScheduleItemKind = "custom"
form_state: dict | None = None
pause_on_failure: bool
# Bookkeeping used to reconnect to a submission that is still (or was)
# in flight on the BEC scan/device server, after this widget has been
# closed and reopened.
request_id: str | None = None
scan_id: str | None = None
scan_number: int | None = None
error: str | None = None
started_at: float | None = None
finished_at: float | None = None
class Schedule(BaseModel):
"""The full, persisted state of one schedule-widget instance."""
schedule_name: str
items: list[ScheduleItem] = Field(default_factory=list)
is_running: bool = False
notes: str = ""
# Settings for two independent, optional features (see notifications.py
# and guard.py) - persisted here alongside the schedule itself so they
# survive a widget restart too, but their *logic* lives entirely in
# those separate modules; this is just where their settings are stored.
notifications: NotificationSettings = Field(default_factory=NotificationSettings)
guard: GuardSettings = Field(default_factory=GuardSettings)
@@ -0,0 +1,75 @@
"""
Pure bookkeeping over a list of `ScheduleItem`s - no Qt, no locking, no I/O.
Kept separate from `schedule_widget.py` for the same reason `guard.py` and
`notifications.py` are: it's testable on its own, and the widget should
only be responsible for Qt/orchestration, not figuring out which item runs
next.
The one invariant everything here assumes and preserves: at any moment, a
schedule's items form a (possibly empty) prefix that is no longer PENDING
(RUNNING - at most one, the one currently executing - COMPLETED, FAILED or
ABORTED), followed by a suffix that is entirely PENDING. `ScheduleWidget`
is responsible for only ever mutating the PENDING suffix (see its module
docstring) and for calling these functions while holding its lock; nothing
here does its own locking.
"""
from __future__ import annotations
from typing import Literal
from bec_lib.logger import bec_logger
from .schedule_item import ScheduleItem, ScheduleItemStatus
logger = bec_logger.logger
def protected_prefix_length(items: list[ScheduleItem]) -> int:
"""How many items, from the start, are no longer PENDING."""
count = 0
for item in items:
if item.status != ScheduleItemStatus.PENDING:
count += 1
else:
break
return count
def index_of(items: list[ScheduleItem], item_id: str | None) -> int | None:
if item_id is None:
return None
for i, item in enumerate(items):
if item.item_id == item_id:
return i
return None
def pick_next_runnable(
items: list[ScheduleItem], repeat_aborted_item, first_item: int = 0
) -> ScheduleItem | Literal["stop"] | None:
"""
What the execution loop should do next: always re-derived from scratch
(never a remembered index/object) so edits made to the PENDING suffix
between calls are picked up correctly.
Returns the next item to run/attach to, the string `"stop"` if an
earlier item failed/was aborted (execution stays parked there until the
operator intervenes), or `None` if every item is COMPLETED.
"""
logger.info(
f"pick next runnable, repeat abort item is {repeat_aborted_item} and first item is {first_item}"
)
for idx, item in enumerate(items):
logger.info(f"item: {item}")
if idx < first_item:
continue
if item.status == ScheduleItemStatus.COMPLETED:
continue
if item.status == ScheduleItemStatus.ABORTED:
if not repeat_aborted_item:
continue
if item.status == ScheduleItemStatus.FAILED:
return "stop"
return item # PENDING, RUNNING or ABORTED if repeat_aborted_item is set (reconciled as still active)
return None
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
{'files': ['scheduler.py']}
@@ -0,0 +1,57 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from bec_widgets.utils.bec_designer import designer_material_icon
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from .scheduler import Scheduler
DOM_XML = """
<ui language='c++'>
<widget class='Scheduler' name='scheduler'>
</widget>
</ui>
"""
class SchedulerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = Scheduler(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon(Scheduler.ICON_NAME)
def includeFile(self):
return "scheduler"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "Scheduler"
def toolTip(self):
return "Scheduler"
def whatsThis(self):
return self.toolTip()
Whitespace-only changes.
@@ -0,0 +1,249 @@
"""
Universal Qt widgets
"""
from functools import partial
from bec_widgets.utils.colors import get_accent_colors
from qtpy.QtCore import Qt
# pylint: disable=E0611
from qtpy.QtGui import QFont
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QDoubleSpinBox,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
LABEL_WIDTH = 118
ROW_MARGINS = (4, 0, 4, 0)
ROW_SPACING = 6
class Group(QGroupBox):
def __init__(self, label, widgets):
super().__init__(label)
self.layout = QVBoxLayout(self) # type: ignore
self.layout.setContentsMargins(6, 6, 6, 6)
self.layout.setSpacing(4)
for widget in widgets:
self.layout.addWidget(widget) # type: ignore
class NumberIndicator(QWidget):
def __init__(self, label="", unit=None, highlight=False, decimals=3):
super().__init__()
layout = QHBoxLayout(self)
layout.setContentsMargins(*ROW_MARGINS)
layout.setSpacing(ROW_SPACING)
self.label = QLabel(label)
self.label.setFixedWidth(LABEL_WIDTH)
self.label.setWordWrap(True)
layout.addWidget(self.label)
self.val = QLabel("-")
self.val.setAlignment(Qt.AlignTop) # type: ignore
layout.addWidget(self.val)
self.unit = unit
self.highlight = highlight
self.decimals = decimals
self.number = 0
if highlight:
font = QFont()
font.setBold(True)
font.setPointSize(14)
self.label.setFont(font)
self.val.setFont(font)
def value(self) -> float:
return self.number
def setLabel(self, label) -> None:
self.label.setText(label)
def setValue(self, number):
self.number = number
text = f"{number:.{int(self.decimals)}f}"
if self.unit is not None:
text = text + " " + self.unit
self.val.setText(text)
class InputNumberField(QWidget):
def __init__(
self,
identifier="",
label="",
unit=None,
prefix=None,
init=0.0,
decimals=1,
single_step=0.1,
ll=-1e6,
hl=1e6,
):
super().__init__()
layout = QHBoxLayout(self)
layout.setContentsMargins(*ROW_MARGINS)
layout.setSpacing(ROW_SPACING)
self.identifier = identifier
self.label = QLabel(label)
self.label.setFixedWidth(LABEL_WIDTH)
self.label.setWordWrap(True)
layout.addWidget(self.label)
self.val = QDoubleSpinBox()
self.val.setRange(ll, hl)
self.val.setDecimals(decimals)
self.val.setSingleStep(single_step)
self.val.setValue(init)
if unit is not None:
self.val.setSuffix(" " + unit)
if prefix is not None:
self.val.setPrefix(prefix + " ")
layout.addWidget(self.val)
self.ll = ll
self.hl = hl
def set_number(self, number):
self.val.setValue(number)
def set_range(self, ll, hl):
self.val.setRange(ll, hl)
self.ll = ll
self.hl = hl
def setUnit(self, unit: str):
self.val.setSuffix(" " + unit)
def get_range(self):
return (self.ll, self.hl)
def has_focus(self) -> bool:
return self.val.hasFocus()
def value(self) -> float:
return self.val.value()
def value_changed_connect(self, func):
"""Connect a function to the Enter/Return key press."""
self.val.valueChanged.connect(
partial(
func, identifier=self.identifier, value_obj=self.val, value=lambda: self.val.value()
)
)
class ComboBox(QWidget):
def __init__(self, identifier="", label="", enums=None):
super().__init__()
layout = QHBoxLayout(self)
layout.setContentsMargins(*ROW_MARGINS)
layout.setSpacing(ROW_SPACING)
self.identifier = identifier
self.label = QLabel(label)
self.label.setFixedWidth(LABEL_WIDTH)
self.label.setWordWrap(True)
layout.addWidget(self.label)
self.value = QComboBox()
for entry in enums or []:
self.value.addItem(entry)
layout.addWidget(self.value)
def set_current_text(self, text):
self.value.setCurrentText(text)
def currentText(self) -> str:
return self.value.currentText()
def has_focus(self) -> bool:
return QApplication.focusWidget() is self.value.view()
def activated_connect(self, func):
"""Connect a function to the Enter/Return key press."""
self.value.activated.connect(
partial(
func,
identifier=self.identifier,
value_obj=self.value,
value=lambda: self.value.currentText(),
index=lambda: self.value.currentIndex(),
)
)
def setDisabled(self, disable):
self.value.setDisabled(disable)
def get_all_texts(self) -> list[str]:
return [self.value.itemText(i) for i in range(self.value.count())]
class Button(QWidget):
def __init__(
self, label=None, label_button: str = "", enabled: bool = False, color: str = "default"
):
super().__init__()
layout = QHBoxLayout(self)
layout.setContentsMargins(*ROW_MARGINS)
layout.setSpacing(ROW_SPACING)
if label is not None:
self.label = QLabel(label)
self.label.setFixedWidth(LABEL_WIDTH)
layout.addWidget(self.label)
self.button = QPushButton(label_button)
self.setEnabled(enabled)
layout.addWidget(self.button)
self.color = color
def clicked_connect(self, func):
"""Connect a function to the button press."""
self.button.clicked.connect(func)
def setEnabled(self, enable: bool = True):
self.button.setEnabled(enable)
self.apply_theme()
def setText(self, text):
self.button.setText(text)
def setColor(self, color: str):
self.color = color
self.apply_theme()
def apply_theme(self):
if self.button.isEnabled():
colors = get_accent_colors()
color = getattr(colors, self.color).name()
self.button.setStyleSheet(f"QPushButton {{ background-color: {color}; color: white; }}")
else:
self.button.setStyleSheet(
"QPushButton {{background-color: rgb(120, 120, 120); color: white;}}"
)
class TextIndicator(QWidget):
def __init__(self, label):
super().__init__()
layout = QHBoxLayout(self)
layout.setContentsMargins(*ROW_MARGINS)
layout.setSpacing(ROW_SPACING)
self.label = QLabel(label)
self.label.setFixedWidth(LABEL_WIDTH)
self.label.setWordWrap(True)
layout.addWidget(self.label)
self.text = QLabel("-")
self.text.setAlignment(Qt.AlignTop) # type: ignore
layout.addWidget(self.text)
def setLabel(self, label) -> None:
self.label.setText(label)
def setText(self, text):
self.text.setText(text)
def setColor(self, color: str):
self.text.setStyleSheet(f"QLabel {{color:{color}}}")
@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from .temperature_controller_plugin import TemperatureControllerPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(TemperatureControllerPlugin())
if __name__ == "__main__": # pragma: no cover
main()
@@ -0,0 +1,248 @@
"""Temperature controller widget"""
from __future__ import annotations
import sys
import time
from collections import deque
import numpy as np
from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme, get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.plots.waveform.waveform import Waveform
# pylint: disable=E0611
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget
from ....devices.univ_temp_cont.univ_temp_cont_enums import (
RAMP_RATE_MAP,
UnivTempContHeaterTcFault,
UnivTempContOpMode,
UnivTempContPower,
UnivTempContRampRateUnit,
UnivTempContSensorBreak,
)
from .qt_widgets import ComboBox, Group, InputNumberField, NumberIndicator, TextIndicator
logger = bec_logger.logger
DEFAULT_WINDOW_S = 600
REFRESH_MS = 200
PLOT_SIGNALS = ["temperature", "setpoint", "output_power"]
READ_CONFIG_SIGNAL_MS = 1000
class TemperatureControllerError(Exception):
"""Temperature Controller specific error"""
class TemperatureController(BECWidget, QWidget):
"""Waveform with predefined curves and one status label."""
PLUGIN = True
ICON_NAME = "mode_heat"
def __init__(self, *arg, parent=None, **kwargs):
super().__init__(parent=parent, *arg, **kwargs)
self.get_bec_shortcuts()
if "univ_temp_cont" not in self.dev:
raise TemperatureControllerError(
"Universal Temperature Controller is not in the config, abort."
)
self._window_s = DEFAULT_WINDOW_S
root_layout = QHBoxLayout(self)
control_layout = QVBoxLayout()
control_layout.setContentsMargins(4, 4, 4, 4)
control_layout.setSpacing(4)
self.sensor_break = TextIndicator("Thermocouple")
self.operating_mode = TextIndicator("Operating mode")
self.output_power = NumberIndicator("Output Power", "%", decimals=0)
self.temperature = NumberIndicator("Temperature", "°C", decimals=0)
self.heater_temperature = NumberIndicator("Heater temperature", "°C", decimals=0)
self.heater_sensor_break = TextIndicator("Heater Thermocouple")
status_group = Group(
"Status",
[
self.sensor_break,
self.operating_mode,
self.output_power,
self.temperature,
self.heater_temperature,
self.heater_sensor_break,
],
)
self.power_mode = ComboBox("power_mode", "Power mode", UnivTempContPower)
self.target_setpoint = InputNumberField(
"target_setpoint", "Target setpoint", "°C", decimals=0, single_step=1, ll=0, hl=850
)
self.setpoint = NumberIndicator("Setpoint", "°C", highlight=True, decimals=0)
self.ramp_rate = InputNumberField(
"ramp-rate", "Ramp rate", decimals=0, single_step=1, ll=0, hl=1e5
)
self.ramp_rate_unit = ComboBox("ramp_rate_unit", "Ramp rate unit", UnivTempContRampRateUnit)
control_group = Group(
"Control",
[
self.power_mode,
self.target_setpoint,
self.setpoint,
self.ramp_rate,
self.ramp_rate_unit,
],
)
control_layout.addWidget(status_group)
control_layout.addWidget(control_group)
root_layout.addLayout(control_layout)
self.waveform = Waveform(parent=self, client=self.client, popups=False)
root_layout.addWidget(self.waveform, stretch=1)
self.waveform.set(title="Title", x_label="X-Label")
self._buffers: dict[str, deque] = {}
self._curves = {}
for signal in PLOT_SIGNALS:
self._buffers[signal] = deque() # (timestamp, value), trimmed by time
self._curves[signal] = self.waveform.plot(
x=[], y=[], label=self.dev.univ_temp_cont.signal.full_name
)
self.bec_dispatcher.connect_slot(
self._on_readback, MessageEndpoints.device_readback(self.dev.univ_temp_cont)
)
self._refresh_timer = QTimer(self)
self._refresh_timer.timeout.connect(self._redraw)
self._refresh_timer.start(REFRESH_MS)
self._config_signal_timer = QTimer(self)
self._config_signal_timer.timeout.connect(self._config_signal_read)
self._config_signal_timer.start(READ_CONFIG_SIGNAL_MS)
self.power_mode.activated_connect(self._on_value_changed)
self.target_setpoint.value_changed_connect(self._on_value_changed)
self.ramp_rate.value_changed_connect(self._on_value_changed)
self.ramp_rate_unit.activated_connect(self._on_value_changed)
@SafeSlot()
def _on_value_changed(self, identifier, _, value, **kwargs):
logger.info(f"Got identifier {identifier} with value {value} and kwargs {kwargs}")
if "index" in kwargs.keys():
value = kwargs["index"]
self.dev.univ_temp_cont[identifier].put(value)
@SafeSlot(dict, dict)
def _on_readback(self, msg: dict, *_):
signals = msg.get("signals", {})
for signal, buf in self._buffers.items():
entry = signals.get(signal)
if entry is None or entry.get("value") is None:
continue
buf.append((entry["timestamp"], entry["value"]))
value = signals["temperature"]["value"]
self.temperature.setValue(value)
value = signals["heater_temperature"]["value"]
self.heater_temperature.setValue(value)
value = signals["output_power"]["value"]
self.output_power.setValue(value)
value = signals["sensor_break"]["value"]
match value:
case UnivTempContSensorBreak.GOOD:
self.sensor_break.setText("OK")
self.sensor_break.setColor(get_accent_colors().success.name())
case UnivTempContSensorBreak.SENSOR_BREAK:
self.sensor_break.setText("FAULT")
self.sensor_break.setColor(get_accent_colors().emergency.name())
case _:
raise TemperatureControllerError(f"Not implemented sensor_break value {value}")
value = signals["heater_tc_fault"]["value"]
match value:
case UnivTempContHeaterTcFault.NO_FAULT:
self.heater_sensor_break.setText("OK")
self.heater_sensor_break.setColor(get_accent_colors().success.name())
case UnivTempContHeaterTcFault.TC_OPEN_FAULT:
self.heater_sensor_break.setText("OPEN")
self.heater_sensor_break.setColor(get_accent_colors().warning.name())
case (
UnivTempContHeaterTcFault.OVER_UNDERVOLTAGE_FAULT
| UnivTempContHeaterTcFault.TC_LOW_FAULT
| UnivTempContHeaterTcFault.TC_HIGH_FAULT
| UnivTempContHeaterTcFault.COLD_JUNC_LOW_FAULT
| UnivTempContHeaterTcFault.COLD_JUNC_HIGH_FAULT
| UnivTempContHeaterTcFault.TC_RANGE_FAULT
| UnivTempContHeaterTcFault.COLD_JUNC_RNG_FAULT
):
self.heater_sensor_break.setText("FAULT")
self.heater_sensor_break.setColor(get_accent_colors().emergency.name())
case _:
raise TemperatureControllerError(
f"Not implemented heater_sensor_break value {value}"
)
value = signals["op_mode"]["value"]
match value:
case UnivTempContOpMode.AUTO:
self.operating_mode.setText("AUTO")
self.operating_mode.setColor(get_accent_colors().success.name())
case UnivTempContOpMode.MANUAL:
self.operating_mode.setText("MANUAL")
self.operating_mode.setColor(get_accent_colors().warning.name())
case UnivTempContOpMode.STANDBY:
self.operating_mode.setText("STANDBY")
self.operating_mode.setColor(get_accent_colors().default.name())
case _:
raise TemperatureControllerError(f"Not implemented op_mode value {value}")
value = signals["power"]["value"]
self.power_mode.set_current_text(value)
value = signals["target_setpoint"]["value"]
self.target_setpoint.set_number(value)
value = signals["setpoint"]["value"]
self.setpoint.setValue(value)
value = signals["ramp_rate"]["value"]
self.ramp_rate.set_number(value)
def _config_signal_read(self):
value = self.dev.univ_temp_cont.ramp_rate_unit.read()
if value is None:
raise TemperatureControllerError("Reading ramp rate unit returned None!")
self.ramp_rate_unit.set_current_text(value)
self.ramp_rate.setUnit(
list(RAMP_RATE_MAP.keys())[list(RAMP_RATE_MAP.values()).index(value)]
)
def _redraw(self):
now = time.time()
cutoff = now - self._window_s
for key, buf in self._buffers.items():
while buf and buf[0][0] < cutoff:
buf.popleft()
if not buf:
continue
t, v = zip(*buf)
self._curves[key].set_data(np.asarray(t) - now, np.asarray(v))
def cleanup(self):
self._refresh_timer.stop()
super().cleanup()
if __name__ == "__main__":
app = QApplication(sys.argv)
apply_theme("light")
dispatcher = BECDispatcher(gui_id="restart_server")
win = TemperatureController()
win.show()
sys.exit(app.exec_())
@@ -0,0 +1 @@
{'files': ['temperature_controller.py']}
@@ -0,0 +1,57 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from bec_widgets.utils.bec_designer import designer_material_icon
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from .temperature_controller import TemperatureController
DOM_XML = """
<ui language='c++'>
<widget class='TemperatureController' name='temperature_controller'>
</widget>
</ui>
"""
class TemperatureControllerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = TemperatureController(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon(TemperatureController.ICON_NAME)
def includeFile(self):
return "temperature_controller"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "TemperatureController"
def toolTip(self):
return "TemperatureController"
def whatsThis(self):
return self.toolTip()
@@ -1,26 +1,41 @@
#######################################
## Experimental Hutch Photon Shutter ##
#######################################
eh_sh:
readoutPriority: baseline
description: Experimental Hutch Photon Shutter
deviceClass: debye_bec.devices.eh_shutter.EHPhotonShutter
deviceConfig:
prefix: "X01DA-"
onFailure: retry
enabled: true
softwareTrigger: false
###################################
## Optical Table ##
###################################
ot_tryu:
readoutPriority: baseline
description: Optical Table Y-Translation Upstream
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-ES-OT:TRYU
onFailure: retry
enabled: true
softwareTrigger: false
# ot_tryu:
# readoutPriority: baseline
# description: Optical Table Y-Translation Upstream
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-ES-OT:TRYU
# onFailure: retry
# enabled: true
# softwareTrigger: false
ot_tryd:
readoutPriority: baseline
description: Optical Table Y-Translation Downstream
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-ES-OT:TRYD
onFailure: retry
enabled: true
softwareTrigger: false
# ot_tryd:
# readoutPriority: baseline
# description: Optical Table Y-Translation Downstream
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-ES-OT:TRYD
# onFailure: retry
# enabled: true
# softwareTrigger: false
ot_es1_trz:
readoutPriority: baseline
@@ -94,45 +109,45 @@ es0filter:
## Slits ES0 ##
###################################
es0sl_trxr:
readoutPriority: baseline
description: End Station slits X-translation Ring-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-ES0-SL:TRXR
onFailure: retry
enabled: true
softwareTrigger: false
# es0sl_trxr:
# readoutPriority: baseline
# description: End Station slits X-translation Ring-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-ES0-SL:TRXR
# onFailure: retry
# enabled: true
# softwareTrigger: false
es0sl_trxw:
readoutPriority: baseline
description: End Station slits X-translation Wall-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-ES0-SL:TRXW
onFailure: retry
enabled: true
softwareTrigger: false
# es0sl_trxw:
# readoutPriority: baseline
# description: End Station slits X-translation Wall-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-ES0-SL:TRXW
# onFailure: retry
# enabled: true
# softwareTrigger: false
es0sl_tryb:
readoutPriority: baseline
description: End Station slits Y-translation Bottom-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-ES0-SL:TRYB
onFailure: retry
enabled: true
softwareTrigger: false
# es0sl_tryb:
# readoutPriority: baseline
# description: End Station slits Y-translation Bottom-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-ES0-SL:TRYB
# onFailure: retry
# enabled: true
# softwareTrigger: false
es0sl_tryt:
readoutPriority: baseline
description: End Station slits X-translation Top-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-ES0-SL:TRYT
onFailure: retry
enabled: true
softwareTrigger: false
# es0sl_tryt:
# readoutPriority: baseline
# description: End Station slits X-translation Top-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-ES0-SL:TRYT
# onFailure: retry
# enabled: true
# softwareTrigger: false
es0sl_center:
readoutPriority: baseline
@@ -266,15 +281,15 @@ es1bm1_trx:
enabled: true
softwareTrigger: false
es1det2_trx:
readoutPriority: baseline
description: End Station SDD 2 X-translation
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-ES1-DET2:TRX
onFailure: retry
enabled: true
softwareTrigger: false
# es1det2_trx:
# readoutPriority: baseline
# description: End Station SDD 2 X-translation
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-ES1-DET2:TRX
# onFailure: retry
# enabled: true
# softwareTrigger: false
###################################
## IC1 + IC2 Manipulator ##
+82 -82
View File
@@ -17,45 +17,45 @@ abs:
## Frontend Slits ##
###################################
sldi_trxr:
readoutPriority: baseline
description: Front-end slit diaphragm X-translation Ring-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-FE-SLDI:TRXR
onFailure: retry
enabled: true
softwareTrigger: false
# sldi_trxr:
# readoutPriority: baseline
# description: Front-end slit diaphragm X-translation Ring-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-FE-SLDI:TRXR
# onFailure: retry
# enabled: true
# softwareTrigger: false
sldi_trxw:
readoutPriority: baseline
description: Front-end slit diaphragm X-translation Wall-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-FE-SLDI:TRXW
onFailure: retry
enabled: true
softwareTrigger: false
# sldi_trxw:
# readoutPriority: baseline
# description: Front-end slit diaphragm X-translation Wall-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-FE-SLDI:TRXW
# onFailure: retry
# enabled: true
# softwareTrigger: false
sldi_tryb:
readoutPriority: baseline
description: Front-end slit diaphragm Y-translation Bottom-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-FE-SLDI:TRYB
onFailure: retry
enabled: true
softwareTrigger: false
# sldi_tryb:
# readoutPriority: baseline
# description: Front-end slit diaphragm Y-translation Bottom-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-FE-SLDI:TRYB
# onFailure: retry
# enabled: true
# softwareTrigger: false
sldi_tryt:
readoutPriority: baseline
description: Front-end slit diaphragm X-translation Top-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-FE-SLDI:TRYT
onFailure: retry
enabled: true
softwareTrigger: false
# sldi_tryt:
# readoutPriority: baseline
# description: Front-end slit diaphragm X-translation Top-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-FE-SLDI:TRYT
# onFailure: retry
# enabled: true
# softwareTrigger: false
sldi_centerx:
readoutPriority: baseline
@@ -101,55 +101,55 @@ sldi_gapy:
## Collimating Mirror ##
###################################
cm_trxu:
readoutPriority: baseline
description: Collimating Mirror X-translation upstream
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-FE-CM:TRXU
onFailure: retry
enabled: true
softwareTrigger: false
# cm_trxu:
# readoutPriority: baseline
# description: Collimating Mirror X-translation upstream
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-FE-CM:TRXU
# onFailure: retry
# enabled: true
# softwareTrigger: false
cm_trxd:
readoutPriority: baseline
description: Collimating Mirror X-translation downstream
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-FE-CM:TRXD
onFailure: retry
enabled: true
softwareTrigger: false
# cm_trxd:
# readoutPriority: baseline
# description: Collimating Mirror X-translation downstream
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-FE-CM:TRXD
# onFailure: retry
# enabled: true
# softwareTrigger: false
cm_tryu:
readoutPriority: baseline
description: Collimating Mirror Y-translation upstream
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-FE-CM:TRYU
onFailure: retry
enabled: true
softwareTrigger: false
# cm_tryu:
# readoutPriority: baseline
# description: Collimating Mirror Y-translation upstream
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-FE-CM:TRYU
# onFailure: retry
# enabled: true
# softwareTrigger: false
cm_trydr:
readoutPriority: baseline
description: Collimating Mirror Y-translation downstream ring
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-FE-CM:TRYDR
onFailure: retry
enabled: true
softwareTrigger: false
# cm_trydr:
# readoutPriority: baseline
# description: Collimating Mirror Y-translation downstream ring
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-FE-CM:TRYDR
# onFailure: retry
# enabled: true
# softwareTrigger: false
cm_trydw:
readoutPriority: baseline
description: Collimating Mirror Y-translation downstream wall
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-FE-CM:TRYDW
onFailure: retry
enabled: true
softwareTrigger: false
# cm_trydw:
# readoutPriority: baseline
# description: Collimating Mirror Y-translation downstream wall
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-FE-CM:TRYDW
# onFailure: retry
# enabled: true
# softwareTrigger: false
cm_bnd:
readoutPriority: baseline
@@ -240,4 +240,4 @@ cm_xstripe:
prefix: X01DA-FE-CM:XSTRIPE
onFailure: retry
enabled: true
softwareTrigger: false
softwareTrigger: false
@@ -15,4 +15,18 @@ curr:
onFailure: buffer
enabled: true
readOnly: true
softwareTrigger: false
bl_status:
readoutPriority: baseline
description: BL status for machine
deviceClass: ophyd.EpicsSignal
deviceConfig:
auto_monitor: false
read_pv: AGEOP-BL:STATUS-X01DA
deviceTags:
- machine
onFailure: buffer
enabled: true
readOnly: false
softwareTrigger: false
@@ -0,0 +1,14 @@
######################################
## Universal Temperature Controller ##
######################################
univ_temp_cont:
readoutPriority: monitored
description: Universal Temperature Controller
deviceClass: debye_bec.devices.univ_temp_cont.univ_temp_cont.UnivTempCont
deviceConfig:
auto_monitor: true
prefix: "X01DA-ES-CRS:"
onFailure: retry
enabled: true
softwareTrigger: false
+156 -142
View File
@@ -1,4 +1,18 @@
###################################
## Optics Photon Shutter ##
###################################
op_sh:
readoutPriority: baseline
description: Optics Hutch Photon Shutter
deviceClass: debye_bec.devices.op_shutter.OPPhotonShutter
deviceConfig:
prefix: "X01DA-"
onFailure: retry
enabled: true
softwareTrigger: false
###################################
## Monochromator ##
###################################
@@ -37,57 +51,57 @@ mo1_roty:
## Optics Slits + Beam Monitor 1 ##
###################################
sl1_trxr:
readoutPriority: baseline
description: Optics slits 1 X-translation Ring-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-OP-SL1:TRXR
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- optics
- slits
# sl1_trxr:
# readoutPriority: baseline
# description: Optics slits 1 X-translation Ring-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-OP-SL1:TRXR
# onFailure: retry
# enabled: true
# softwareTrigger: false
# deviceTags:
# - optics
# - slits
sl1_trxw:
readoutPriority: baseline
description: Optics slits 1 X-translation Wall-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-OP-SL1:TRXW
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- optics
- slits
# sl1_trxw:
# readoutPriority: baseline
# description: Optics slits 1 X-translation Wall-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-OP-SL1:TRXW
# onFailure: retry
# enabled: true
# softwareTrigger: false
# deviceTags:
# - optics
# - slits
sl1_tryb:
readoutPriority: baseline
description: Optics slits 1 Y-translation Bottom-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-OP-SL1:TRYB
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- optics
- slits
# sl1_tryb:
# readoutPriority: baseline
# description: Optics slits 1 Y-translation Bottom-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-OP-SL1:TRYB
# onFailure: retry
# enabled: true
# softwareTrigger: false
# deviceTags:
# - optics
# - slits
sl1_tryt:
readoutPriority: baseline
description: Optics slits 1 X-translation Top-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-OP-SL1:TRYT
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- optics
- slits
# sl1_tryt:
# readoutPriority: baseline
# description: Optics slits 1 X-translation Top-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-OP-SL1:TRYT
# onFailure: retry
# enabled: true
# softwareTrigger: false
# deviceTags:
# - optics
# - slits
bm1_try:
readoutPriority: baseline
@@ -158,55 +172,55 @@ sl1_gapy:
## Focusing Mirror ##
###################################
fm_trxu:
readoutPriority: baseline
description: Focusing Mirror X-translation upstream
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-OP-FM:TRXU
onFailure: retry
enabled: true
softwareTrigger: false
# fm_trxu:
# readoutPriority: baseline
# description: Focusing Mirror X-translation upstream
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-OP-FM:TRXU
# onFailure: retry
# enabled: true
# softwareTrigger: false
fm_trxd:
readoutPriority: baseline
description: Focusing Mirror X-translation downstream
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-OP-FM:TRXD
onFailure: retry
enabled: true
softwareTrigger: false
# fm_trxd:
# readoutPriority: baseline
# description: Focusing Mirror X-translation downstream
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-OP-FM:TRXD
# onFailure: retry
# enabled: true
# softwareTrigger: false
fm_tryd:
readoutPriority: baseline
description: Focusing Mirror Y-translation downstream
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-OP-FM:TRYD
onFailure: retry
enabled: true
softwareTrigger: false
# fm_tryd:
# readoutPriority: baseline
# description: Focusing Mirror Y-translation downstream
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-OP-FM:TRYD
# onFailure: retry
# enabled: true
# softwareTrigger: false
fm_tryur:
readoutPriority: baseline
description: Focusing Mirror Y-translation upstream ring
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-OP-FM:TRYUR
onFailure: retry
enabled: true
softwareTrigger: false
# fm_tryur:
# readoutPriority: baseline
# description: Focusing Mirror Y-translation upstream ring
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-OP-FM:TRYUR
# onFailure: retry
# enabled: true
# softwareTrigger: false
fm_tryuw:
readoutPriority: baseline
description: Focusing Mirror Y-translation upstream wall
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-OP-FM:TRYUW
onFailure: retry
enabled: true
softwareTrigger: false
# fm_tryuw:
# readoutPriority: baseline
# description: Focusing Mirror Y-translation upstream wall
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-OP-FM:TRYUW
# onFailure: retry
# enabled: true
# softwareTrigger: false
fm_bnd:
readoutPriority: baseline
@@ -293,57 +307,57 @@ fm_ztcp:
## Optics Slits + Beam Monitor 2 ##
###################################
sl2_trxr:
readoutPriority: baseline
description: Optics slits 2 X-translation Ring-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-OP-SL2:TRXR
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- optics
- slits
# sl2_trxr:
# readoutPriority: baseline
# description: Optics slits 2 X-translation Ring-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-OP-SL2:TRXR
# onFailure: retry
# enabled: true
# softwareTrigger: false
# deviceTags:
# - optics
# - slits
sl2_trxw:
readoutPriority: baseline
description: Optics slits 2 X-translation Wall-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-OP-SL2:TRXW
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- optics
- slits
# sl2_trxw:
# readoutPriority: baseline
# description: Optics slits 2 X-translation Wall-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-OP-SL2:TRXW
# onFailure: retry
# enabled: true
# softwareTrigger: false
# deviceTags:
# - optics
# - slits
sl2_tryb:
readoutPriority: baseline
description: Optics slits 2 Y-translation Bottom-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-OP-SL2:TRYB
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- optics
- slits
# sl2_tryb:
# readoutPriority: baseline
# description: Optics slits 2 Y-translation Bottom-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-OP-SL2:TRYB
# onFailure: retry
# enabled: true
# softwareTrigger: false
# deviceTags:
# - optics
# - slits
sl2_tryt:
readoutPriority: baseline
description: Optics slits 2 X-translation Top-edge
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X01DA-OP-SL2:TRYT
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- optics
- slits
# sl2_tryt:
# readoutPriority: baseline
# description: Optics slits 2 X-translation Top-edge
# deviceClass: ophyd_devices.EpicsMotorEC
# deviceConfig:
# prefix: X01DA-OP-SL2:TRYT
# onFailure: retry
# enabled: true
# softwareTrigger: false
# deviceTags:
# - optics
# - slits
bm2_try:
readoutPriority: baseline
@@ -408,4 +422,4 @@ sl2_gapy:
softwareTrigger: false
deviceTags:
- optics
- slits
- slits
+55 -17
View File
@@ -7,11 +7,16 @@ from typing import TYPE_CHECKING
from ophyd import Component as Cpt
from ophyd import EpicsSignal, EpicsSignalRO
from ophyd_devices import CompareStatus, DeviceStatus
from ophyd_devices import CompareStatus
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from .utils.bl_status_enum import BlStatus
if TYPE_CHECKING:
from bec_lib.devicemanager import ScanInfo
from bec_server.device_server.device_server import DeviceManagerDS
TIMEOUT_FOR_PV = 5
class AbsorberError(Exception):
@@ -38,6 +43,13 @@ class STATUS(int, enum.Enum):
UNDEFINED = 14
class BL_ENABLE(int, enum.Enum):
"""Beamline enable"""
DISABLE = 0
ENABLE = 1
class Absorber(PSIDeviceBase):
"""Class for the Frontend Absorber"""
@@ -55,30 +67,56 @@ class Absorber(PSIDeviceBase):
string=True,
doc="Absorber Status",
)
close4bl = Cpt(EpicsSignal, suffix="CLOSE4BL", kind="config", doc="Beamline enable")
def __init__(self, *, name: str, prefix: str = "", scan_info: ScanInfo | None = None, **kwargs):
super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs)
def __init__(
self,
*,
name: str,
prefix: str = "",
scan_info: ScanInfo | None = None,
device_manager: DeviceManagerDS | None = None,
**kwargs,
):
super().__init__(
name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs
)
self.device_manager = device_manager
self.timeout_for_move = 10
# Wait for connection on all components, ensure IOC is connected
self.wait_for_connection(all_signals=True, timeout=5)
def open(self) -> DeviceStatus | None:
"""Open the Absorber"""
if self.status.get() == STATUS.CLOSED:
self.request.put(1)
status_open = CompareStatus(self.status, STATUS.OPEN, timeout=self.timeout_for_move)
status = status_open
return status
else:
return None
def open(self, force: bool = False, wait: bool = True) -> CompareStatus | None:
"""Open the Absorber
def close(self) -> DeviceStatus | None:
Args:
force(bool): If needed, set bl status to enable and bl enable to ENABLE, defaults to False
"""
if self.status.get() == STATUS.CLOSED:
if force:
if self.device_manager.devices.bl_status.get() == BlStatus.OFFLINE:
status = self.device_manager.devices.bl_status.put(BlStatus.ATTENDED)
status.wait(timeout=TIMEOUT_FOR_PV)
if self.close4bl.get() == BL_ENABLE.DISABLE:
status = self.close4bl.set(BL_ENABLE.ENABLE)
status.wait(timeout=TIMEOUT_FOR_PV)
self.request.put(1)
status = CompareStatus(self.status, STATUS.OPEN, timeout=self.timeout_for_move)
if wait:
status.wait(timeout=self.timeout_for_move)
return None
return status
return None
def close(self, wait: bool = True) -> CompareStatus | None:
"""Close the Absorber"""
if self.status.get() == STATUS.OPEN:
self.request.put(1)
status_close = CompareStatus(self.status, STATUS.CLOSED, timeout=self.timeout_for_move)
status = status_close
status = CompareStatus(self.status, STATUS.CLOSED, timeout=self.timeout_for_move)
if wait:
status.wait(timeout=self.timeout_for_move)
return None
return status
else:
return None
return None
+87
View File
@@ -0,0 +1,87 @@
"""Experimental Hutch Photon Shutter"""
from __future__ import annotations
import enum
from typing import TYPE_CHECKING
from ophyd import Component as Cpt
from ophyd import EpicsSignal, EpicsSignalRO
from ophyd_devices import CompareStatus
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
if TYPE_CHECKING:
from bec_lib.devicemanager import ScanInfo
TIMEOUT_FOR_PV = 5
class STATUS(int, enum.Enum):
"""Shutter States"""
NOT_CLOSED = 0
CLOSED = 1
class BL_ENABLE(int, enum.Enum):
"""Beamline enable"""
DISABLE = 0
ENABLE = 1
class EHPhotonShutter(PSIDeviceBase):
"""Class for the Experimental Hutch Photon Shutter"""
USER_ACCESS = ["open", "close"]
request_open = Cpt(
EpicsSignal, suffix="EH1-PSYS:SH-A-OPEN-SET", kind="config", doc="Open Shutter"
)
request_close = Cpt(
EpicsSignal, suffix="EH1-PSYS:SH-A-CLOSE-SET", kind="config", doc="Close Shutter"
)
status = Cpt(
EpicsSignalRO,
suffix="EH1-PSYS:SH-A-CLOSE",
kind="normal",
auto_monitor=True,
doc="Shutter Status",
)
status_string = Cpt(
EpicsSignalRO,
suffix="EH1-PSYS:SH-A-CLOSE",
kind="normal",
auto_monitor=True,
string=True,
doc="Shutter Status",
)
def __init__(self, *, name: str, prefix: str = "", scan_info: ScanInfo | None = None, **kwargs):
super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs)
self.timeout_for_move = 10
# Wait for connection on all components, ensure IOC is connected
self.wait_for_connection(all_signals=True, timeout=5)
def open(self, wait: bool = True) -> CompareStatus | None:
"""Open the Shutter"""
if self.status.get() == STATUS.CLOSED:
self.request_open.put(1)
status = CompareStatus(self.status, STATUS.NOT_CLOSED, timeout=self.timeout_for_move)
if wait:
status.wait(timeout=self.timeout_for_move)
return None
return status
return None
def close(self, wait: bool = True) -> CompareStatus | None:
"""Close the Shutter"""
if self.status.get() == STATUS.NOT_CLOSED:
self.request_close.put(1)
status = CompareStatus(self.status, STATUS.CLOSED, timeout=self.timeout_for_move)
if wait:
status.wait(timeout=self.timeout_for_move)
return None
return status
return None
@@ -14,6 +14,7 @@ from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from typeguard import typechecked
from debye_bec.devices.ionization_chambers.ionization_chamber_enums import (
GAIN_MAP,
AmplifierEnable,
AmplifierFilter,
AmplifierGain,
@@ -120,12 +121,16 @@ class IonizationChamber0(PSIDeviceBase):
self.timeout_for_pvwait = 2.5
super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs)
@typechecked
def set_gain(self, gain: Literal["1e6", "1e7", "5e7", "1e8", "1e9"]) -> None:
def set_gain(self, gain: float) -> None:
"""Configure the gain setting of the specified channel
Args:
gain (Literal['1e6', '1e7', '5e7', '1e8', '1e9']) : Desired gain
gain(float): Amplifier gain to set. Accepts either a float/int or a string,
e.g. 1e6 or "1e6". Must be one of: 1e6, 1e7, 5e7, 1e8, 1e9.
Raises:
ValueError: If `gain` cannot be converted to a float, or does not
match one of the supported gain values.
"""
if self.amp.cOnOff.get() == AmplifierEnable.OFF:
@@ -134,17 +139,21 @@ class IonizationChamber0(PSIDeviceBase):
self.amp.cOnOff.put(AmplifierEnable.ON)
status.wait(self.timeout_for_pvwait)
match gain:
case "1e6":
self.amp.cGain_ENUM.put(AmplifierGain.G1E6)
case "1e7":
self.amp.cGain_ENUM.put(AmplifierGain.G1E7)
case "5e7":
self.amp.cGain_ENUM.put(AmplifierGain.G5E7)
case "1e8":
self.amp.cGain_ENUM.put(AmplifierGain.G1E8)
case "1e9":
self.amp.cGain_ENUM.put(AmplifierGain.G1E9)
try:
key = float(gain)
except (TypeError, ValueError) as exc:
raise ValueError(f"Invalid gain: {gain!r}") from exc
try:
enum_val = GAIN_MAP[key]
except KeyError as exc:
raise ValueError(
f"Unsupported gain: {gain!r}. Must be one of {sorted(GAIN_MAP)}"
) from exc
self.amp.cGain_ENUM.put(enum_val)
status = CompareStatus(self.amp.cGain_ENUM, enum_val)
status.wait(self.timeout_for_pvwait)
def set_filter(
self, value: Literal["1us", "3us", "10us", "30us", "100us", "300us", "1ms", "3ms"]
@@ -263,7 +272,7 @@ class IonizationChamber0(PSIDeviceBase):
self.gmes.gas2_req.set(gas2).wait(timeout=3)
self.gmes.conc2_req.set(conc2).wait(timeout=3)
status = TransitionStatus(self.gmes.status.get(), [0, 1])
status = TransitionStatus(self.gmes.status, [0, 1])
self.cancel_on_stop(status)
self.gmes.fill.put(1)
if wait:
@@ -373,6 +382,7 @@ class IonizationChamber2(IonizationChamber0):
}
hv_en = Dcpt(hv_en_signals)
class Pips(IonizationChamber0):
"""Pips, prefix should be 'X01DA-'."""
@@ -19,6 +19,15 @@ class AmplifierGain(int, enum.Enum):
G1E9 = 4
GAIN_MAP = {
1e6: AmplifierGain.G1E6,
1e7: AmplifierGain.G1E7,
5e7: AmplifierGain.G5E7,
1e8: AmplifierGain.G1E8,
1e9: AmplifierGain.G1E9,
}
class AmplifierFilter(int, enum.Enum):
"""Enum class for the filter of the channel"""
+3
View File
@@ -178,6 +178,9 @@ class NidaqControl(Device):
heartbeat = Cpt(EpicsSignal, suffix="NIDAQ-Heartbeat", kind=Kind.config, auto_monitor=True)
time_left = Cpt(EpicsSignalRO, suffix="NIDAQ-TimeLeft", kind=Kind.config, auto_monitor=True)
epics_mode = Cpt(EpicsSignal, suffix="NIDAQ-EpicsMode", kind=Kind.config, auto_monitor=True)
epics_max_reset = Cpt(EpicsSignal, suffix="NIDAQ-EpicsMaxReset", kind=Kind.config, auto_monitor=True)
ai_chans = Cpt(EpicsSignal, suffix="NIDAQ-AIChans", kind=Kind.config, auto_monitor=True)
ci_chans = Cpt(EpicsSignal, suffix="NIDAQ-CIChans", kind=Kind.config, auto_monitor=True)
di_chans = Cpt(EpicsSignal, suffix="NIDAQ-DIChans", kind=Kind.config, auto_monitor=True)
+7
View File
@@ -58,3 +58,10 @@ class EncoderFactors(int, enum.Enum):
X1 = 4
X2 = 5
X4 = 6
class EpicsMode(int, enum.Enum):
"""Mode when sending through EPICS"""
MEAN = 0
MAX = 1
+118
View File
@@ -0,0 +1,118 @@
"""Optics Photon Shutter"""
from __future__ import annotations
import enum
from typing import TYPE_CHECKING
from ophyd import Component as Cpt
from ophyd import EpicsSignal, EpicsSignalRO
from ophyd_devices import CompareStatus
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from .utils.bl_status_enum import BlStatus
if TYPE_CHECKING:
from bec_lib.devicemanager import ScanInfo
from bec_server.device_server.device_server import DeviceManagerDS
TIMEOUT_FOR_PV = 5
class OPPhotonShutterError(Exception):
"""Shutter specific exception"""
class STATUS(int, enum.Enum):
"""Shutter States"""
NOT_CLOSED = 0
CLOSED = 1
class BL_ENABLE(int, enum.Enum):
"""Beamline enable"""
DISABLE = 0
ENABLE = 1
class OPPhotonShutter(PSIDeviceBase):
"""Class for the Optics Photon Shutter"""
USER_ACCESS = ["open", "close"]
request_open = Cpt(
EpicsSignal, suffix="OP-PSYS:SH-A-OPEN-SET", kind="config", doc="Open Shutter"
)
request_close = Cpt(
EpicsSignal, suffix="OP-PSYS:SH-A-CLOSE-SET", kind="config", doc="Close Shutter"
)
status = Cpt(
EpicsSignalRO,
suffix="OP-PSYS:SH-A-CLOSE",
kind="normal",
auto_monitor=True,
doc="Shutter Status",
)
status_string = Cpt(
EpicsSignalRO,
suffix="OP-PSYS:SH-A-CLOSE",
kind="normal",
auto_monitor=True,
string=True,
doc="Shutter Status",
)
close4bl = Cpt(EpicsSignal, suffix="FE-BST1:CLOSE4BL", kind="config", doc="Beamline enable")
def __init__(
self,
*,
name: str,
prefix: str = "",
scan_info: ScanInfo | None = None,
device_manager: DeviceManagerDS | None = None,
**kwargs,
):
super().__init__(
name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs
)
self.device_manager = device_manager
self.timeout_for_move = 10
# Wait for connection on all components, ensure IOC is connected
self.wait_for_connection(all_signals=True, timeout=5)
def open(self, force: bool = False, wait: bool = True) -> CompareStatus | None:
"""Open the Shutter
Args:
force(bool): If needed, set bl status to enable and bl enable to ENABLE, defaults to False
"""
if self.status.get() == STATUS.CLOSED:
if force:
if self.device_manager.devices.bl_status.get() == BlStatus.OFFLINE:
status = self.device_manager.devices.bl_status.put(BlStatus.ATTENDED)
status.wait(timeout=TIMEOUT_FOR_PV)
if self.close4bl.get() == BL_ENABLE.DISABLE:
status = self.close4bl.set(BL_ENABLE.ENABLE)
status.wait(timeout=TIMEOUT_FOR_PV)
self.request_open.put(1)
status = CompareStatus(self.status, STATUS.NOT_CLOSED, timeout=self.timeout_for_move)
if wait:
status.wait(timeout=self.timeout_for_move)
return None
return status
return None
def close(self, wait: bool = True) -> CompareStatus | None:
"""Close the Shutter"""
if self.status.get() == STATUS.NOT_CLOSED:
self.request_close.put(1)
status = CompareStatus(self.status, STATUS.CLOSED, timeout=self.timeout_for_move)
if wait:
status.wait(timeout=self.timeout_for_move)
return None
return status
return None
+85 -5
View File
@@ -5,8 +5,9 @@ from __future__ import annotations
import enum
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from ophyd import Component as Cpt
from ophyd import EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV
from ophyd import EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV, Signal
from ophyd.status import DeviceStatus
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from ophyd_devices.utils.errors import DeviceStopError
@@ -14,6 +15,8 @@ from ophyd_devices.utils.errors import DeviceStopError
if TYPE_CHECKING:
from bec_lib.devicemanager import ScanInfo
logger = bec_logger.logger
class Status(int, enum.Enum):
"""Enum class for the status field"""
@@ -34,10 +37,67 @@ class OpMode(int, enum.Enum):
ERRORMODE = 3
N_FOILS = 38
class CurrentFoilSignal(Signal):
"""
Soft signal reflecting the description of the foil currently
inserted. Recomputed whenever 'ref_rb', 'status' or any of the
`foilNN` signals on the parent device change.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._subscribed = False
def _foil_signal(self):
if self.parent.status.get() != Status.INSERTED:
return None
idx = self.parent.ref_rb.get()
try:
idx = int(round(idx))
except (TypeError, ValueError):
return None
return getattr(self.parent, f"foil{idx:02d}", None)
def _compute(self):
sig = self._foil_signal()
if sig is None:
return ""
try:
return sig.get()
except Exception:
logger.exception("current_foil: failed to read %s", sig.name)
return ""
def get(self, **kwargs):
self._readback = self._compute()
return self._readback
def _on_upstream_change(self, value=None, timestamp=None, **kwargs):
old_value = self._readback
new_value = self._compute()
self._readback = new_value
self._run_subs(
sub_type=self.SUB_VALUE, old_value=old_value, value=new_value, timestamp=timestamp
)
def subscribe_to_parent_signals(self):
if self._subscribed:
return
self.parent.ref_rb.subscribe(self._on_upstream_change, run=False)
self.parent.status.subscribe(self._on_upstream_change, run=False)
for i in range(1, N_FOILS + 1):
getattr(self.parent, f"foil{i:02d}").subscribe(self._on_upstream_change, run=False)
self._subscribed = True
self._readback = self._compute()
class Reffoilchanger(PSIDeviceBase):
"""Class for the ES2 Reference Foil Changer"""
USER_ACCESS = ["insert"]
USER_ACCESS = ["get_all_foils", "insert"]
inserted = Cpt(
EpicsSignalRO, suffix="ES2-REF:TRY-FilterInserted", kind="config", doc="Inserted indicator"
@@ -53,18 +113,30 @@ class Reffoilchanger(PSIDeviceBase):
EpicsSignal, suffix="ES2-REF:SELN-FilterState-ENUM_RBV", kind="config", doc="Status"
)
status_string = Cpt(
EpicsSignal, suffix="ES2-REF:SELN-FilterState-ENUM_RBV", kind="config", doc="Status", string=True
EpicsSignal,
suffix="ES2-REF:SELN-FilterState-ENUM_RBV",
kind="config",
doc="Status",
string=True,
)
op_mode = Cpt(
EpicsSignalWithRBV, suffix="ES2-REF:SELN-OpMode-ENUM", kind="config", doc="Status"
)
op_mode_string = Cpt(
EpicsSignalWithRBV, suffix="ES2-REF:SELN-OpMode-ENUM", kind="config", doc="Status", string=True
EpicsSignalWithRBV,
suffix="ES2-REF:SELN-OpMode-ENUM",
kind="config",
doc="Status",
string=True,
)
ref_set = Cpt(EpicsSignal, suffix="ES2-REF:SELN-SET", kind="config", doc="Requested reference")
ref_rb = Cpt(
EpicsSignalRO, suffix="ES2-REF:SELN-RB", kind="config", doc="Currently set reference"
EpicsSignalRO,
suffix="ES2-REF:SELN-RB",
kind="config",
doc="Currently set reference (angle)",
)
current_foil = Cpt(CurrentFoilSignal, value="", kind="normal", doc="Inserted reference")
foil01 = Cpt(EpicsSignalRO, suffix="ES-REFFOIL:FOIL01.DESC", kind="config", doc="Foil 01")
foil02 = Cpt(EpicsSignalRO, suffix="ES-REFFOIL:FOIL02.DESC", kind="config", doc="Foil 02")
@@ -107,6 +179,7 @@ class Reffoilchanger(PSIDeviceBase):
def __init__(self, *, name: str, prefix: str = "", scan_info: ScanInfo | None = None, **kwargs):
super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs)
self.current_foil.subscribe_to_parent_signals()
self.foils = [
self.foil01,
@@ -149,6 +222,13 @@ class Reffoilchanger(PSIDeviceBase):
self.foil38,
]
def get_all_foils(self) -> list[str]:
"""Returns a list of strings of all available foils"""
foils_list = []
for foil in self.foils:
foils_list.append(foil.get())
return foils_list
def insert(self, ref: str, wait: bool = False) -> DeviceStatus:
"""Insert a reference
@@ -0,0 +1,86 @@
"""Universal Temperature Controller"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ophyd import Component as Cpt
from ophyd import EpicsSignal, EpicsSignalRO
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from univ_temp_cont_enums import (
UnivTempContAutoTune,
UnivTempContConnection,
UnivTempContHeaterTcFault,
UnivTempContOpMode,
UnivTempContPower,
UnivTempContRampRateUnit,
UnivTempContSensorBreak,
UnivTempContSetpointSelect,
UnivTempContThermocouple,
)
if TYPE_CHECKING:
from bec_lib.devicemanager import ScanInfo
TIMEOUT_FOR_PV = 5
class UnivTempCont(PSIDeviceBase):
"""Class for the Universal Temperature Controller"""
temperature = Cpt(EpicsSignalRO, suffix="TEMPERATURE", kind="normal", doc="Temperature")
timestamp = Cpt(EpicsSignalRO, suffix="TIMESTAMP", kind="omitted", doc="Current timestamp")
setpoint = Cpt(EpicsSignalRO, suffix="SETPOINT", kind="normal", doc="Setpoint on Controller")
target_setpoint = Cpt(
EpicsSignal, suffix="TARGET-SETPOINT", kind="normal", doc="Target Setpoint"
)
output_power = Cpt(EpicsSignalRO, suffix="OUTPUT-POWER", kind="normal", doc="Output Power %")
ramp_rate = Cpt(EpicsSignal, suffix="RAMP-RATE", kind="normal", doc="Ramp rate")
pb = Cpt(EpicsSignal, suffix="PB", kind="config", doc="Controller PB value")
ti = Cpt(EpicsSignal, suffix="TI", kind="config", doc="Controller TI value")
td = Cpt(EpicsSignal, suffix="TD", kind="config", doc="Controller TD value")
op_mode = Cpt(EpicsSignal, suffix="OP-MODE", kind="normal", doc="Operation mode")
setpoint_select = Cpt(
EpicsSignal, suffix="SETPOINT-SELECT", kind="config", doc="Local or remote setpoint"
)
ramp_rate_unit = Cpt(EpicsSignal, suffix="RAMP-RATE-UNIT", kind="config", doc="Ramp rate unit")
autotune = Cpt(EpicsSignal, suffix="AUTOTUNE", kind="config", doc="Autotune function")
sensor_break = Cpt(
EpicsSignalRO, suffix="SENSOR-BREAK", kind="normal", doc="Thermocouple Status"
)
thermocouple = Cpt(EpicsSignal, suffix="THERMOCOUPLE", kind="config", doc="Thermocouple type")
power = Cpt(EpicsSignal, suffix="POWER", kind="normal", doc="Power status")
controller_type = Cpt(EpicsSignalRO, suffix="TYPE", kind="config", doc="Controller type")
heartbeat = Cpt(EpicsSignalRO, suffix="HEARTBEAT", kind="normal", doc="Heartbeat")
connection = Cpt(
EpicsSignalRO, suffix="CONNECTION", kind="normal", doc="Connection to Eurotherm Controller"
)
heater_temperature = Cpt(
EpicsSignalRO,
suffix="HEATER-TEMPERATURE",
kind="normal",
doc="Temperature of heating element",
)
heater_tc_fault = Cpt(
EpicsSignalRO,
suffix="HEATER-TC-FAULT",
kind="normal",
doc="TC Fault inside heating element",
)
def __init__(self, *, name: str, prefix: str = "", scan_info: ScanInfo | None = None, **kwargs):
super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs)
self.timeout_for_pvwait = 5
# Wait for connection on all components, ensure IOC is connected
self.wait_for_connection(all_signals=True, timeout=5)
def on_connected(self) -> None:
"""
Called after the device is connected and its signals are connected.
Default values for signals should be set here.
"""
if self.setpoint_select != UnivTempContSetpointSelect.REMOTE:
self.setpoint_select.set(UnivTempContSetpointSelect.REMOTE).wait(
timeout=self.timeout_for_pvwait
)
@@ -0,0 +1,83 @@
"""Enums for the universal temperature controller"""
from enum import Enum
class UnivTempContOpMode(int, Enum):
"""Class for operating mode"""
AUTO = 0
MANUAL = 1
STANDBY = 2
class UnivTempContSetpointSelect(int, Enum):
"""Class for setpoint selection"""
LOCAL = 0
REMOTE = 1
class UnivTempContRampRateUnit(int, Enum):
"""Class for ramp rate unit selection"""
DEG_C_MIN = 0
DEG_C_H = 1
DEG_C_S = 2
RAMP_RATE_MAP = {
"°C/min": UnivTempContRampRateUnit.DEG_C_MIN,
"°C/h": UnivTempContRampRateUnit.DEG_C_H,
"°C/s": UnivTempContRampRateUnit.DEG_C_S,
}
class UnivTempContAutoTune(int, Enum):
"""Class for autotune function"""
NOTUNE = 0
TUNE = 1
class UnivTempContSensorBreak(int, Enum):
"Class for sensor break status"
GOOD = 0
SENSOR_BREAK = 1
class UnivTempContThermocouple(int, Enum):
"""Class for thermocouple type"""
TYPE_J = 0
Type_K = 1
class UnivTempContPower(int, Enum):
"""Class for power mode"""
AUTO = 0
STANDBY = 1
CONFIGURATION = 2
class UnivTempContConnection(int, Enum):
"""Class for connection status"""
DISCONNECTED = 0
CONNECTED = 1
class UnivTempContHeaterTcFault(int, Enum):
"""Class for the heater thermocouple fault"""
NO_FAULT = 0
TC_OPEN_FAULT = 1
OVER_UNDERVOLTAGE_FAULT = 2
TC_LOW_FAULT = 3
TC_HIGH_FAULT = 4
COLD_JUNC_LOW_FAULT = 5
COLD_JUNC_HIGH_FAULT = 6
TC_RANGE_FAULT = 7
COLD_JUNC_RNG_FAULT = 8
@@ -0,0 +1,8 @@
import enum
class BlStatus(str, enum.Enum):
"""Beamline status enum"""
OFFLINE = 0
ATTENDED = 1
REMOTE = 2
+12 -2
View File
@@ -1,6 +1,6 @@
from bec_server.file_writer.default_writer import DefaultFormat
import debye_bec.bec_widgets.widgets.digital_twin.x01da_parameters as bl
import debye_bec.bec_ipython_client.plugins.digital_twin_core.x01da_parameters as bl
class DebyeNexusStructure(DefaultFormat):
@@ -236,7 +236,9 @@ class DebyeNexusStructure(DefaultFormat):
self.configuration.get("nidaq", {}).get("nidaq_add_chans", {}).get("value")
)
rle = self.configuration.get("nidaq", {}).get("nidaq_enable_compression", {}).get("value")
rle = (
self.configuration.get("nidaq", {}).get("nidaq_enable_compression", {}).get("value")
)
measurement_mode = entry.create_group(name="mode")
measurement_mode.attrs["NX_class"] = "NX_CHAR"
@@ -288,6 +290,14 @@ class DebyeNexusStructure(DefaultFormat):
)
ref_trans.attrs["NX_class"] = "NX_CHAR"
foil = (
self.device_manager.devices.reffoilchanger.read(cached=True)
.get("reffoilchanger_current_foil")
.get("value")
)
ref_foil = ai2ai4_active.create_dataset(name="Reference", data=foil)
ref_foil.attrs["NX_class"] = "NX_CHAR"
main_data = entry.create_group(name="data")
main_data.attrs["NX_class"] = "NXdata"