WIP: widget development #105

Draft
hitz_s wants to merge 24 commits from feat/widget-development into main
52 changed files with 5184 additions and 1570 deletions
@@ -0,0 +1,259 @@
"""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 NidaqState
from ...devices.op_shutter import STATUS as OP_PH_STATUS
logger = bec_logger.logger
EMIN = -100
EMAX = 200
MIN_RING_CURRENT = 5 # Minimum ring current to use auto-gain
NOMINAL_RING_CURRENT = 400 # Nominal ring current of SLS2
MONO_VELOCITY = 20 # Move velocity in deg/s
TIMEOUT_MONO_PV = 5 # Timeout to set a PV on the mono
TIMEOUT_MONO_MOVE = 30 # Timeout to finish a movement on the mono
AVAILABLE_GAINS = [1e6, 1e7, 5e7, 1e8, 1e9] # ascending order
MIN_SIGNAL = 0.05 # Minimum signal to count as valid signal
FULL_SCALE_V = 10.0 # NIDAQ AI full-scale range
SAFETY_MARGIN = 0.9 # keep max signal under 90% of full scale
class AutoGainError(Exception):
"""AutoGain specific error"""
class AutoGain:
"""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
def start(
self,
element: str,
edge: str,
amplifier: list[str] | None = None,
comp_ring_current: bool = True,
) -> None:
"""Start the auto-gain sequence. Measure the signals of the specified
amplifiers and set the gains accordingly. Makes sure there is actually beam available.
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 -> ['ic0', 'ic1', 'ic2', 'pips']
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
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 amplifier is None:
amplifier = ["ic0", "ic1", "ic2", "pips"]
# 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
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"
)
# 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}
# Check if amplifieres are switched on
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
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
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")
logger.info("All checks done, start preparing for measurement")
# Get initial monochromator position and velocity
init_pos = self.dev.mo1_bragg.position.get()
init_vel = self.dev.mo1_bragg.velocity.get()
logger.info(f"Move mono to start of {emin} eV")
status = self.dev.mo1_bragg.move(emin)
status.wait(TIMEOUT_MONO_MOVE)
# Set NIDAQ to max mode
# TODO implement
# Set gains to lowest gain
for name, ch in active_channels.items():
lowest_gain = AVAILABLE_GAINS[0]
ch["self.dev"].set_gain(lowest_gain)
ch["gain"] = lowest_gain
remeasure = True
logger.info(f"Start measurement from {emin} eV to {emax} eV")
while remeasure:
# 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 == 0:
raise AutoGainError("Ring current dropped to 0 mA right before measurement")
# Scan range, recording the peak NIDAQ signal per channel
status = self.dev.mo1_bragg.velocity.put(MONO_VELOCITY)
status.wait(TIMEOUT_MONO_PV)
self.dev.mo1_bragg.move(emax).wait(timeout=TIMEOUT_MONO_MOVE)
status.wait(TIMEOUT_MONO_MOVE)
for name, ch in active_channels.items():
data[name] = max(data[name], ch["signal"].get())
# Rest max values of NIDAQ signals
# TODO implement
# 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 == 0:
raise AutoGainError("Ring current dropped to 0 mA during measurement")
ring_current = (ring_current_1 + ring_current_2) / 2
# Move back to first monochromator position
status = self.dev.mo1_bragg.move(emin)
# Choose gain per channel based on the max signal recorded during the scan
remeasure = False
for name, ch in active_channels.items():
raw_signal = data[name]
logger.info(f"Raw signal for self.device {name} is {raw_signal} V")
if comp_ring_current:
raw_signal = raw_signal * NOMINAL_RING_CURRENT / ring_current
logger.info(f"Compensate for ring current, new raw signal is {raw_signal} V")
if raw_signal < MIN_SIGNAL:
logger.info(f"Raw signal for self.device {name} is below {MIN_SIGNAL}")
# Choose next gain to be 100x the current gain, or if this gain does not exist,
# choose the next smaller one
if ch["gain"] == AVAILABLE_GAINS[-1]:
logger.warning(
f"Amplifier of {name} at highest gain {ch['gain']} and still not"
+ f" measured signal above {MIN_SIGNAL}"
)
else:
next_gain = AVAILABLE_GAINS[
bisect_right(AVAILABLE_GAINS, ch["gain"] * 100) - 1
]
ch["self.dev"].set_gain(next_gain)
logger.info(
f"Setting gain of self.device {name} to {next_gain:.0e} and remeasure"
)
remeasure = True
else:
gain = max(
(
g
for g in AVAILABLE_GAINS
if raw_signal / ch["gain"] * g <= FULL_SCALE_V * SAFETY_MARGIN
),
default=min(AVAILABLE_GAINS),
)
ch["self.dev"].set_gain(gain)
logger.info(f"Calculated final gain for {name} of {gain:.0e}")
# Wait for mono to return to start position
status.wait(TIMEOUT_MONO_MOVE)
# Reset NIDAQ to mean mode
# TODO implement
# Wait for mono to move to initial position and reset velocity
status = self.dev.mo1_bragg.move(init_pos)
status.wait(TIMEOUT_MONO_MOVE)
self.dev.mo1_bragg.velocity.put(init_vel)
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
@@ -0,0 +1,3 @@
from .beamline import get_parameters
parameters = get_parameters()
@@ -0,0 +1,51 @@
import socket
from bec_lib import bec_logger
from .types import BeamlineId
logger = bec_logger.logger
def get_beamline_id() -> BeamlineId:
"""
Based on the bec servers hostname, tries to extract the beamline
identifier (e.g. x01da, x10da, etc).
Raises:
ValueError if beamline cannot be extracted from hostname or beamline not implemented.
"""
bec_hostname = socket.gethostname()
start = bec_hostname.find("x")
if start != -1:
beamline = bec_hostname[start : start + 5]
match beamline:
case "x01da":
return BeamlineId.X01DA
case "x10da":
return BeamlineId.X10DA
case _:
raise ValueError(f"Not implemented beamline {beamline}")
else:
logger.warning(f"Failed to extract beamline from bec server hostname {bec_hostname}")
choice = input("Do you want to manually select a beamline? (yes/no): ").strip().lower()
if choice in ["yes", "y"]:
bl = input(f"Choose from: {[bl.value for bl in BeamlineId]}")
if bl in BeamlineId:
logger.info(f"Manually selected beamline {bl}")
return BeamlineId(bl)
else:
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
File diff suppressed because it is too large Load Diff
@@ -1,296 +1,296 @@
"""
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])
@@ -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.digital_twin_core.digital_twin_core import DigitalTwinCore
from debye_bec.bec_ipython_client.plugins.auto_gain import AutoGain
digital_twin = DigitalTwinCore()
logger.success("Digital Twin Core loaded. Use 'digital_twin' to access it.")
auto_gain = AutoGain()
logger.success("Auto-Gain module loaded. Use 'auto_gain' to access it.")
+101 -1
View File
@@ -17,6 +17,7 @@ _Widgets = {
"DigitalTwin": "DigitalTwin",
"RestartServer": "RestartServer",
"ScanControlXAS": "ScanControlXAS",
"Scheduler": "Scheduler",
}
@@ -69,7 +70,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 +116,102 @@ 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):
"""
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,
) -> "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,
):
"""
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"""
@@ -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)
@@ -15,6 +15,7 @@ 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 = {
@@ -22,4 +23,5 @@ widget_icons = {
"DigitalTwin": "lightbulb",
"RestartServer": "restart_alt",
"ScanControlXAS": "tune",
"Scheduler": "assignment_add",
}
@@ -1,3 +0,0 @@
from .beamline import get_parameters
parameters = get_parameters()
@@ -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")
@@ -3,7 +3,6 @@ Digital Twin: Custom BEC widget to support the beamline alignment.
"""
import sys
from pathlib import Path
from typing import Literal, cast
import numpy as np
@@ -35,38 +34,18 @@ from qtpy.QtWidgets import (
QWidget,
)
from ....bec_ipython_client.plugins.digital_twin_core.beamline import get_beamline_id
from ....bec_ipython_client.plugins.digital_twin_core.digital_twin_core import DigitalTwinCore
from ....bec_ipython_client.plugins.digital_twin_core.types import ConfigDict
from ..edge_selector import EdgeSelector
from .beamline import get_beamline_id
from .calculations.calc_positions import calc_positions
from .calculations.calc_sideview import calc_sideview
from .calculations.calc_surfaces import calc_surfaces
from .calculations.calc_varia import (
calc_beamsize,
cm_critical_angle,
cm_reflectivity,
cm_stripe_to_trx,
cm_trx_to_stripe,
fm_ideal_pitch,
fm_reflectivity,
fm_stripe_to_trx,
fm_trx_to_stripe,
mo1_bragg_angle,
mo1_energy_resolution,
sldi_gap_to_acc,
table_to_smpl_pos,
)
from .panels.input_panel import InputPanel
from .panels.mover_panel import MoverPanel
from .panels.plots import SideviewPlot, SurfacePlots
from .panels.settings_panel import SettingsPanel
from .types import ConfigDict
from .widgets.qt_widgets import ComboBox, InputNumberField
logger = bec_logger.logger
OFFSET_FILE_X01DA = Path(__file__).with_name("x01da_offsets.yaml")
OFFSET_FILE_X10DA = Path(__file__).with_name("x10da_offsets.yaml")
X01DA_E_MIN = 4500
X01DA_E_MAX = 60000
X10DA_E_MIN = 4500
@@ -85,17 +64,12 @@ class DigitalTwin(BECWidget, QWidget):
super().__init__(parent=parent, *arg, **kwargs)
self.get_bec_shortcuts()
self.core = DigitalTwinCore()
self.beamline = get_beamline_id()
# Debugging, override beamline!
# self.beamline = BeamlineId.X10DA
self.offset_file = Path()
match self.beamline:
case "x01da":
self.offset_file = OFFSET_FILE_X01DA
case "x10da":
self.offset_file = OFFSET_FILE_X10DA
# Check if devices are all in config
self.check_bec_config()
self.bec_dispatcher.connect_slot(
@@ -123,8 +97,8 @@ class DigitalTwin(BECWidget, QWidget):
self.plot_layout = QVBoxLayout(self.plot_widget)
self.plot_layout.setContentsMargins(4, 4, 4, 4)
self.plot_layout.setSpacing(6)
self.sideview_plot = SideviewPlot()
self.surface_plots = SurfacePlots(self.beamline)
self.sideview_plot = SideviewPlot(self.core)
self.surface_plots = SurfacePlots(self.beamline, self.core)
self.plot_layout.addWidget(self.sideview_plot, stretch=1)
self.plot_layout.addWidget(self.surface_plots, stretch=1)
self.plot_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
@@ -172,7 +146,6 @@ class DigitalTwin(BECWidget, QWidget):
self.edge_selector_energy = 0.0
self.bragg_angle = 0.0
self.qy = 0.0
self.offsets = {}
# Initialize all values
self.load_offsets(recalculate=False)
@@ -400,9 +373,9 @@ class DigitalTwin(BECWidget, QWidget):
fm_qy = self.qy
cm_stripe = self.input.cm_stripe.currentText()
cm_trx = cm_stripe_to_trx(cm_stripe)
cm_trx = self.core.cm_stripe_to_trx(cm_stripe)
fm_stripe = self.input.fm_stripe.currentText()
fm_trx = fm_stripe_to_trx(fm_stripe)
fm_trx = self.core.fm_stripe_to_trx(fm_stripe)
assert cm_trx is not None, f"No cm_trx found for given stripe {cm_stripe}!"
assert fm_trx is not None, f"No fm_trx found for given stripe {fm_stripe}!"
@@ -412,7 +385,7 @@ class DigitalTwin(BECWidget, QWidget):
smpl = self.input.smpl.value()
case ComboBox():
table = self.input.smpl.currentText()
smpl = table_to_smpl_pos(table)
smpl = self.core.table_to_smpl_pos(table)
config: ConfigDict = {
"energy": self.input.energy.value(),
@@ -434,16 +407,7 @@ class DigitalTwin(BECWidget, QWidget):
# Apply offsets
if apply_offset:
for axis, _ in config.items():
if axis in self.offsets:
axis_offsets = self.offsets[axis]
if "modifier" in axis_offsets and "offset" in axis_offsets:
for idx, rng in enumerate(axis_offsets["modifier"]["range"]):
if rng[0] < config[axis_offsets["modifier"]["axis"]] < rng[1]:
config[axis] += axis_offsets["offset"][idx]
break
elif "offset" in axis_offsets:
config[axis] += axis_offsets["offset"]
config = self.core.apply_offsets(config, nested_config=False)
# Convert to SI units!
config["h_acc"] *= 1e-3
@@ -469,12 +433,12 @@ class DigitalTwin(BECWidget, QWidget):
mo1_bragg = self.dev.mo1_bragg.read(cached=True)
sldi_gapx = self.dev.sldi_gapx.read(cached=True)["sldi_gapx"]["value"]
sldi_gapy = self.dev.sldi_gapy.read(cached=True)["sldi_gapy"]["value"]
h_acc, v_acc = sldi_gap_to_acc(sldi_gapx, sldi_gapy)
h_acc, v_acc = self.core.sldi_gap_to_acc(sldi_gapx, sldi_gapy)
cm_trx = self.dev.cm_trx.read(cached=True)["cm_trx"]["value"]
cm_stripe = cm_trx_to_stripe(-cm_trx)
cm_stripe = self.core.cm_trx_to_stripe(-cm_trx)
cm_pitch = self.dev.cm_rotx.read(cached=True)["cm_rotx"]["value"]
fm_trx = self.dev.fm_trx.read(cached=True)["fm_trx"]["value"]
fm_stripe = fm_trx_to_stripe(-fm_trx)
fm_stripe = self.core.fm_trx_to_stripe(-fm_trx)
fm_rotx = self.dev.fm_rotx.read(cached=True)["fm_rotx"]["value"]
fm_rotx_real = 2 * cm_pitch - fm_rotx
@@ -483,7 +447,7 @@ class DigitalTwin(BECWidget, QWidget):
smpl = self.dev.ot_es1_trz.read(cached=True)["ot_es1_trz"]["value"]
case ComboBox():
table = self.input.smpl.currentText()
smpl = table_to_smpl_pos(table)
smpl = self.core.table_to_smpl_pos(table)
raw = { # Config in SI units!
"energy": mo1_bragg["mo1_bragg"]["value"],
@@ -592,22 +556,13 @@ class DigitalTwin(BECWidget, QWidget):
pos["ot_es1_trz"] = self.dev.ot_es1_trz.read(cached=True)["ot_es1_trz"]["value"]
# Removing offsets
for axis, _ in pos.items():
if axis in self.offsets:
axis_offsets = self.offsets[axis]
if "modifier" in axis_offsets and "offset" in axis_offsets:
for idx, rng in enumerate(axis_offsets["modifier"]["range"]):
if rng[0] < pos[axis_offsets["modifier"]["axis"]] < rng[1]:
pos[axis] -= axis_offsets["offset"][idx]
break
elif "offset" in axis_offsets:
pos[axis] -= axis_offsets["offset"]
pos = self.core.remove_offsets(pos)
self.input.energy.set_number(self.dev.mo1_bragg.read(cached=True)["mo1_bragg"]["value"])
h_acc, v_acc = sldi_gap_to_acc(pos["sldi_gapx"], pos["sldi_gapy"])
h_acc, v_acc = self.core.sldi_gap_to_acc(pos["sldi_gapx"], pos["sldi_gapy"])
self.input.sldi_hacc.set_number(h_acc * 1e3)
self.input.sldi_vacc.set_number(v_acc * 1e3)
self.input.cm_stripe.set_current_text(cm_trx_to_stripe(-pos["cm_trx"]))
self.input.cm_stripe.set_current_text(self.core.cm_trx_to_stripe(-pos["cm_trx"]))
self.input.cm_pitch.set_number(pos["cm_rotx"])
if abs(pos["mo1_trx"]) > 5:
mo1_mode = "Monochromatic"
@@ -617,7 +572,7 @@ class DigitalTwin(BECWidget, QWidget):
self.input.mo1_xtal.set_current_text(
self.dev.mo1_bragg.read(cached=True)["mo1_bragg_crystal_current_xtal_string"]["value"]
)
fm_stripe = fm_trx_to_stripe(-pos["fm_trx"])
fm_stripe = self.core.fm_trx_to_stripe(-pos["fm_trx"])
self.input.fm_stripe.set_current_text(fm_stripe)
fm_rotx_real = 2 * pos["cm_rotx"] - pos["fm_rotx"]
self.input.fm_rotx.set_number(fm_rotx_real)
@@ -628,10 +583,10 @@ class DigitalTwin(BECWidget, QWidget):
self.input.smpl.set_number(pos["ot_es1_trz"])
case ComboBox():
table = self.ask_table_selection(self.input.smpl.currentText())
smpl = table_to_smpl_pos(table)
smpl = self.core.table_to_smpl_pos(table)
self.input.smpl.set_current_text(table)
fm_focx, fm_focy = calc_beamsize(
fm_focx, fm_focy = self.core.calc_beamsize(
h_acc, v_acc, fm_stripe, -fm_rotx_real * 1e-3, pos["fm_bnd_radius"] * 1e6, smpl
)
if fm_focx < 0.08 and fm_focy < 0.08:
@@ -690,19 +645,9 @@ class DigitalTwin(BECWidget, QWidget):
Defaults to True
"""
if self.offsets == {}:
# Load offsets
if not self.offset_file.exists():
raise FileNotFoundError(f"Offset file not found: {self.offset_file}")
with self.offset_file.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
raise ValueError(f"Expected a YAML mapping, got {type(data).__name__}")
self.offsets = data
self.core.load_offsets()
if self.core.offsets != {}:
# Offsets were loaded
if recalculate:
self.calc_assistant(identifier="init")
@@ -711,8 +656,7 @@ class DigitalTwin(BECWidget, QWidget):
self.settings.offsets_status.setColor(get_accent_colors().success.name())
self.settings.show_offsets.enable_button(True)
else:
# Unload offsets
self.offsets = {}
# Offsets were unloaded
self.calc_assistant(identifier="init")
self.settings.load_offsets.setText("Load")
@@ -736,7 +680,7 @@ class DigitalTwin(BECWidget, QWidget):
intro_label.setWordWrap(True)
layout.addWidget(intro_label)
file = QLabel(str(self.offset_file))
file = QLabel(str(self.core.offset_file))
file.setWordWrap(True)
font = QFont()
font.setItalic(True)
@@ -753,7 +697,9 @@ class DigitalTwin(BECWidget, QWidget):
def represent_sequence(self, tag, sequence, *_):
return super().represent_sequence(tag, sequence, flow_style=True)
text_edit.setPlainText(yaml.dump(self.offsets, Dumper=InlineListDumper, sort_keys=False))
text_edit.setPlainText(
yaml.dump(self.core.offsets, Dumper=InlineListDumper, sort_keys=False)
)
layout.addWidget(text_edit)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
@@ -793,9 +739,9 @@ class DigitalTwin(BECWidget, QWidget):
Updates the plots for the reality scene
"""
config = self.get_reality_config()
data = calc_sideview(config)
data = self.core.calc_sideview(config)
self.sideview_plot.update_curves("reality", data=data)
surfaces = calc_surfaces(config)
surfaces = self.core.calc_surfaces(config)
self.surface_plots.update_surfaces(scene="reality", data=surfaces)
@SafeSlot()
@@ -825,7 +771,7 @@ class DigitalTwin(BECWidget, QWidget):
) # Remove brackets from xtal name to conform with parameters
xtal = cast(Literal["Si111", "Si311"], xtal)
energy = self.input.energy.value()
self.input.mo1_eres.setValue(mo1_energy_resolution(xtal, energy))
self.input.mo1_eres.setValue(self.core.mo1_energy_resolution(xtal, energy))
def calc_cm_reflectivity(self):
"""
@@ -834,9 +780,11 @@ class DigitalTwin(BECWidget, QWidget):
cm_stripe = self.input.cm_stripe.currentText()
cm_pitch = -self.input.cm_pitch.value() * 1e-3
energy = self.input.energy.value()
self.input.cm_refl.setValue(100 * cm_reflectivity(cm_stripe, cm_pitch, energy))
self.input.cm_refl.setValue(100 * self.core.cm_reflectivity(cm_stripe, cm_pitch, energy))
self.input.cm_refl.setLabel(f"Reflectivity at \n{energy:.0f} eV")
self.input.cm_refl_harm.setValue(100 * cm_reflectivity(cm_stripe, cm_pitch, 3 * energy))
self.input.cm_refl_harm.setValue(
100 * self.core.cm_reflectivity(cm_stripe, cm_pitch, 3 * energy)
)
self.input.cm_refl_harm.setLabel(f"Reflectivity at \n{3*energy:.0f} eV")
def calc_fm_reflectivity(self):
@@ -850,9 +798,11 @@ class DigitalTwin(BECWidget, QWidget):
else:
fm_rotx = -self.input.fm_rotx_ideal.value() * 1e-3
energy = self.input.energy.value()
self.input.fm_refl.setValue(100 * fm_reflectivity(fm_stripe, fm_rotx, energy))
self.input.fm_refl.setValue(100 * self.core.fm_reflectivity(fm_stripe, fm_rotx, energy))
self.input.fm_refl.setLabel(f"Reflectivity at \n{energy:.0f} eV")
self.input.fm_refl_harm.setValue(100 * fm_reflectivity(fm_stripe, fm_rotx, 3 * energy))
self.input.fm_refl_harm.setValue(
100 * self.core.fm_reflectivity(fm_stripe, fm_rotx, 3 * energy)
)
self.input.fm_refl_harm.setLabel(f"Reflectivity at \n{3*energy:.0f} eV")
def calc_cm_fm_harm_suppr(self):
@@ -872,33 +822,23 @@ class DigitalTwin(BECWidget, QWidget):
Updates the sideview plot based on the assistant values
"""
config = self.get_assistant_config(apply_offset=True)
data = calc_sideview(config)
data = self.core.calc_sideview(config)
self.sideview_plot.update_curves("assistant", data)
def calc_assistant_surfaces(self):
"""
Updates the surface plot based on the assistant values
"""
surfaces = calc_surfaces(self.get_assistant_config())
surfaces = self.core.calc_surfaces(self.get_assistant_config())
self.surface_plots.update_surfaces(scene="assistant", data=surfaces)
def calc_positions(self):
"""
Calculates the positions for the axes based on the assistant values
"""
out = calc_positions(self.beamline, self.get_assistant_config())
# Apply offsets
for axis, axis_data in out.items():
if axis in self.offsets:
axis_offsets = self.offsets[axis]
if "modifier" in axis_offsets and "offset" in axis_offsets:
for idx, rng in enumerate(axis_offsets["modifier"]["range"]):
if rng[0] < out[axis_offsets["modifier"]["axis"]]["value"] < rng[1]:
axis_data["value"] += axis_offsets["offset"][idx]
break
elif "offset" in axis_offsets:
axis_data["value"] += axis_offsets["offset"]
config = self.get_assistant_config()
out = self.core.calc_positions(self.beamline, config)
out = self.core.apply_offsets(out, nested_config=True)
self.mover.sldi_gapx.set_target(out["sldi_gapx"]["value"])
self.mover.sldi_gapy.set_target(out["sldi_gapy"]["value"])
@@ -952,7 +892,7 @@ class DigitalTwin(BECWidget, QWidget):
cm_pitch = -self.dev.cm_rotx.read(cached=True)["cm_rotx"]["value"] * 1e-3
mo1_mode = cast(Literal["Monochromatic", "Pinkbeam"], self.input.mo1_mode.currentText())
energy = self.input.energy.value()
theta, _ = mo1_bragg_angle(mo1_mode, d_spacing, energy, cm_pitch)
theta, _ = self.core.mo1_bragg_angle(mo1_mode, d_spacing, energy, cm_pitch)
self.bragg_angle = theta
self.input.mo1_bragg_angle.setValue(theta / np.pi * 180)
@@ -983,12 +923,12 @@ class DigitalTwin(BECWidget, QWidget):
smpl = self.input.smpl.value()
case ComboBox():
table = self.input.smpl.currentText()
smpl = table_to_smpl_pos(table)
smpl = self.core.table_to_smpl_pos(table)
sldi_hacc = self.input.sldi_hacc.value() * 1e-3
sldi_vacc = self.input.sldi_vacc.value() * 1e-3
fm_focx = self.input.fm_focx.value()
fm_focy = self.input.fm_focy.value()
fm_rotx, qy = fm_ideal_pitch(
fm_rotx, qy = self.core.fm_ideal_pitch(
fm_focus, fm_stripe, smpl, sldi_hacc, sldi_vacc, fm_focx, fm_focy
)
self.qy = qy
@@ -1000,7 +940,7 @@ class DigitalTwin(BECWidget, QWidget):
"""
cm_stripe = cast(Literal["Si", "Pt", "Rh"], self.input.cm_stripe.currentText())
energy = self.input.energy.value()
self.input.cm_pitch_critical.setValue(-cm_critical_angle(cm_stripe, energy) * 1e3)
self.input.cm_pitch_critical.setValue(-self.core.cm_critical_angle(cm_stripe, energy) * 1e3)
if __name__ == "__main__":
@@ -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.digital_twin_core.types import BeamlineId
from ..widgets.qt_widgets import (
Button,
ComboBox,
@@ -7,7 +7,7 @@ from typing import Literal
# pylint: disable=E0611
from qtpy.QtWidgets import QVBoxLayout, QWidget
from ..types import BeamlineId
from .....bec_ipython_client.plugins.digital_twin_core.types import BeamlineId
from ..widgets.move_widget import AbsorberWidget, MoveWidget
from ..widgets.qt_widgets import 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)))
@@ -15,9 +15,10 @@ from qtpy.QtCore import QObject, QPropertyAnimation, Qt, QThread
from qtpy.QtGui import QTransform
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QPushButton, QWidget
from .....bec_ipython_client.plugins.digital_twin_core.types import BeamlineId
# pylint: disable=E0402
from .....devices.absorber import STATUS as ABS_STATUS
from ..types import BeamlineId
logger = bec_logger.logger
@@ -292,15 +293,13 @@ class MotionWorker(QObject):
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()
# 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:
@@ -336,7 +335,8 @@ class MotionWorker(QObject):
self.finished.emit()
break
self.finished.emit()
except:
except Exception as e:
logger.error(f"Error during movement of {self.motor}: {e}")
self.error.emit()
self.finished.emit()
@@ -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,623 @@
"""
The dialog behind the schedule widget's "Add..."/"Edit..." buttons.
Rather than asking the operator to remember and type
`scans.xas_simple_scan(12000, 14000, 2, 10)`-style commands, this presents:
- a "Scan" tab: BEC's own `bec_widgets` `ScanControl` widget, embedded
as-is - scan selection, its live-generated per-scan argument form,
docs tooltips, metadata, "recall last scan parameters", all of it.
Reusing it instead of a plugin-owned reimplementation means this stays
in sync with BEC's scan capabilities for free, and looks/behaves exactly
like the scan controls an operator already knows from elsewhere in the
GUI. `ScanControl.button_run_scan` ("Start") is hidden here: this dialog
only ever wants the configured scan name/args/kwargs, never an
immediate submission - see `_collect_scan_result`.
- a "Move" tab: pick a device and a target value/relative flag;
- a "Digital Twin" tab: the beamline-alignment `DigitalTwin` widget,
embedded the same way as ScanControl. Instead of submitting anything
itself, OK captures a *snapshot* of `DigitalTwin.get_assistant_config()`
and stores it - execution later calls `move_all_axes(...)` with that
frozen config, so editing the (possibly separately open) Digital Twin
widget afterwards never affects an already-added schedule item, exactly
like a Scan item's captured args/kwargs aren't affected by reopening
ScanControl elsewhere. See `_collect_digital_twin_result`.
- an "Other" tab: a free-text field for anything else (including RPC
calls to other widgets), plus a couple of beamline-specific quick-fill
forms (ionization chamber gas mix, reference foil).
Whichever tab is used, the dialog's only output is the same kind of plain
command string the executor already knows how to run - this dialog adds a
friendlier way to *build* that string, it doesn't change what happens with
it afterwards. `kind`/`form_state` are carried along purely so "Edit..."
can reopen the dialog pre-filled instead of asking the user to start over
- except for the Digital Twin tab, where "Edit..." currently falls back to
showing the generated command as read/write text on the "Other" tab rather
than reloading the captured config back into DigitalTwin's input fields;
see the note on `_apply_initial`.
The Digital Twin item reuses `kind="move"` (not a new kind): it submits
through `scans.mv(...)`, exactly like the plain Move tab, so it should be
treated the same way everywhere else in the plugin that branches on kind -
not guard-protected, and counted under the "Movements" notification
toggle. A `form_state["source"] = "digital_twin"` marker is only used
locally, by this dialog, to tell the two apart when reopening for Edit.
"""
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,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QScrollArea,
QTabWidget,
QVBoxLayout,
QWidget,
)
from .qt_widgets import MyButton
logger = bec_logger.logger
# Tab indices, named instead of magic numbers now that there are four -
# see _collect_result()/_apply_initial().
_TAB_SCAN = 0
_TAB_MOVE = 1
_TAB_DIGITAL_TWIN = 2
_TAB_OTHER = 3
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
if self.beamline in ["x01da", "x10da"]:
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 ..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.EdgeSelector = EdgeSelector
self.ScanControlXAS = ScanControlXAS
layout = QVBoxLayout(self)
self.tabs = QTabWidget()
layout.addWidget(self.tabs)
self._build_scan_tab()
self._build_move_tab()
if self.beamline in ["x01da", "x10da"]:
self._build_digital_twin_tab()
self._build_custom_tab()
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 {})
# ------------------------------------------------------------------ #
# Scan tab - embeds BEC's own ScanControl widget
# ------------------------------------------------------------------ #
def _build_scan_tab(self):
tab = QWidget()
layout = QVBoxLayout(tab)
# 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.beamline in ["x01da", "x10da"]:
self.scan_control = self.ScanControlXAS(parent=tab, client=self._client)
else:
self.scan_control = ScanControl(parent=tab, client=self._client)
self.scan_control.button_run_scan.hide()
layout.addWidget(self.scan_control)
self.tabs.addTab(tab, "Scan")
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_tab(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 tab
# ------------------------------------------------------------------ #
def _build_move_tab(self):
tab = QWidget()
form = QFormLayout(tab)
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.tabs.addTab(tab, "Move")
def _adjust_spinbox(self, _) -> None:
prec = self._dev[self.move_device_combo.currentText()].precision
units = self._dev[self.move_device_combo.currentText()].egu()
ll = self._dev[self.move_device_combo.currentText()].low_limit
hl = self._dev[self.move_device_combo.currentText()].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()
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, "relative": relative},
}
# ------------------------------------------------------------------ #
# Digital Twin tab - embeds the beamline-alignment DigitalTwin widget
# ------------------------------------------------------------------ #
def _build_digital_twin_tab(self):
tab = QWidget()
outer = QVBoxLayout(tab)
# 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.tabs.addTab(tab, "Digital Twin")
def _collect_digital_twin_result(self) -> dict:
config = self.digital_twin.get_assistant_config()
# beamline = self.digital_twin.beamline
# # Init the class when the scheduler is opened
# digital_twin = DigitalTwinCore()
# # The command below would then execute the movement
# digital_twin.move_with_config(config)
cmd = f"digital_twin.move_with_config({config})"
return {"command": f"{cmd}", "kind": "custom", "form_state": {"text": cmd}}
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.")
# ------------------------------------------------------------------ #
# Custom tab
# ------------------------------------------------------------------ #
def _build_custom_tab(self):
tab = QWidget()
layout = QVBoxLayout(tab)
layout.addWidget(
QLabel(
"Free-form command, evaluated against `scans` and `dev` - use this for "
"anything the other tabs 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)
if self.beamline in ["x01da", "x10da"]:
abs_form = self._create_abs_form()
if abs_form is not None:
layout.addWidget(abs_form)
ic_form = self._create_ionization_chamber_form()
if ic_form is not None:
layout.addWidget(ic_form)
reffoil_form = self._create_reffoil_form()
if reffoil_form is not None:
layout.addWidget(reffoil_form)
auto_gain_form = self._create_auto_gain_form()
if auto_gain_form is not None:
layout.addWidget(auto_gain_form)
layout.addStretch(1)
self.tabs.addTab(tab, "Other")
def _create_abs_form(self):
if "abs" in self._dev:
abs_group = QGroupBox("Frontend Absorber")
layout = QVBoxLayout(abs_group)
form = QFormLayout()
layout.addLayout(form)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
self.abs_selector = QComboBox()
self.abs_selector.addItems(["Open", "Force open", "Close"])
form.addRow("Action", self.abs_selector)
button_layout = QHBoxLayout()
generate_cmd = MyButton("Generate command", "default")
button_layout.addWidget(generate_cmd)
button_layout.addStretch(1)
layout.addLayout(button_layout)
generate_cmd.clicked.connect(self._generate_abs_command)
return abs_group
return None
def _generate_abs_command(self):
match self.abs_selector.currentText():
case "Open":
suffix = "open()"
case "Force open":
suffix = "open(force=True)"
case "Close":
suffix = "close()"
cmd = f"dev.abs.{suffix}"
self.custom_edit.setText(cmd)
def _create_ionization_chamber_form(self):
if all(key in self._dev for key in ("ic0", "ic1", "ic2")):
ic_group = QGroupBox("Ionization chamber filling")
layout = QVBoxLayout(ic_group)
form = QFormLayout()
layout.addLayout(form)
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)
button_layout = QHBoxLayout()
generate_cmd = MyButton("Generate command", "default")
button_layout.addWidget(generate_cmd)
button_layout.addStretch(1)
layout.addLayout(button_layout)
self.conc1.valueChanged.connect(self._equalize_ic_conc)
self.conc2.valueChanged.connect(self._equalize_ic_conc)
generate_cmd.clicked.connect(self._generate_ic_command)
return ic_group
return None
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 _generate_ic_command(self):
if self.conc1.value() + self.conc2.value() != 100:
return
match self.ic_selector.currentText():
case "IC0":
ic = "ic0"
case "IC1":
ic = "ic1"
case "IC2":
ic = "ic2"
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)"
)
self.custom_edit.setText(cmd)
def _create_reffoil_form(self):
if "reffoilchanger" in self._dev:
reffoil_group = QGroupBox("Reference foil changer")
layout = QVBoxLayout(reffoil_group)
form = QFormLayout()
layout.addLayout(form)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
self.reffoil_selector = QComboBox()
available_foils = self._dev.reffoilchanger.get_all_foils()
self.reffoil_selector.addItems(available_foils)
form.addRow("Reference foil", self.reffoil_selector)
button_layout = QHBoxLayout()
generate_cmd = MyButton("Generate command", "default")
button_layout.addWidget(generate_cmd)
button_layout.addStretch(1)
layout.addLayout(button_layout)
generate_cmd.clicked.connect(self._generate_reffoil_command)
return reffoil_group
return None
def _generate_reffoil_command(self):
cmd = f"dev.reffoilchanger.insert(ref='{self.reffoil_selector.currentText()}', wait=True)"
self.custom_edit.setText(cmd)
def _create_auto_gain_form(self):
auto_gain_group = QGroupBox("Auto Gain")
layout = QVBoxLayout(auto_gain_group)
edge_selector_layout = QHBoxLayout()
edge_selector_label = QLabel("Absorption edge:")
self.edge_selector_button = MyButton("Choose", "default")
self.edge_label = QLabel("No edge selected")
edge_selector_layout.addWidget(edge_selector_label)
edge_selector_layout.addWidget(self.edge_selector_button)
edge_selector_layout.addWidget(self.edge_label)
edge_selector_layout.addStretch()
self.edge_element = None
self.edge_edge = None
layout.addLayout(edge_selector_layout)
layout.addWidget(QLabel("Auto gain on"))
form = QFormLayout()
layout.addLayout(form)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
self.ic0_check = QCheckBox("")
form.addRow("IC0", self.ic0_check)
self.ic1_check = QCheckBox("")
form.addRow("IC1", self.ic1_check)
self.ic2_check = QCheckBox("")
form.addRow("IC2", self.ic2_check)
self.pips_check = QCheckBox("")
form.addRow("PIPS", self.pips_check)
button_layout = QHBoxLayout()
generate_cmd = MyButton("Generate command", "default")
button_layout.addWidget(generate_cmd)
button_layout.addStretch(1)
layout.addLayout(button_layout)
self.edge_selector_button.clicked.connect(self._update_edge)
generate_cmd.clicked.connect(self._generate_auto_gain_command)
return auto_gain_group
def _update_edge(self, *_):
match self.beamline:
case "x01da":
dlg = self.EdgeSelector(self)
case "x10da":
dlg = self.EdgeSelector(self)
case _:
dlg = self.EdgeSelector(self)
if dlg.exec_():
self.edge_energy = dlg.selected_energy
self.edge_label.setText(
f"{dlg.selected_element}, {dlg.selected_edge}-edge, {dlg.selected_energy:0.1f} eV"
)
self.edge_element = dlg.selected_element
self.edge_edge = dlg.selected_edge
def _generate_auto_gain_command(self):
if self.edge_edge is None or self.edge_element is None:
return
amplifiers = []
for amp, name in [
(self.ic0_check, "ic0"),
(self.ic1_check, "ic1"),
(self.ic2_check, "ic2"),
(self.pips_check, "pips"),
]:
if amp.isChecked():
amplifiers.append(name)
if amplifiers == []:
return
cmd = f"auto_gain.start(element={self.edge_element}, edge={self.edge_edge}, {amplifiers}, comp_ring_current=True)"
self.custom_edit.setText(cmd)
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):
kind = initial.get("kind")
state = initial.get("form_state") or {}
if kind == "scan" and state.get("scan_name"):
self._prefill_scan_tab(
state["scan_name"], state.get("args") or [], state.get("kwargs") or {}
)
self.tabs.setCurrentIndex(_TAB_SCAN)
elif kind == "move" and state.get("source") == "digital_twin":
# Reloading a captured config back into DigitalTwin's own input
# fields would need inverting get_assistant_config()'s unit
# conversions and mode branching (fm_focus, mo1_mode, ...)
# field-by-field - not implemented yet. Fall back to showing
# the generated command as read/write text instead of silently
# dropping the captured config; the "Add" (=Ok) button below
# will just resubmit that text unchanged unless it's edited.
self.custom_edit.setText(initial.get("command", ""))
self.tabs.setCurrentIndex(_TAB_OTHER)
elif kind == "move" and state.get("device_name"):
idx = self.move_device_combo.findText(state["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)))
self.tabs.setCurrentIndex(_TAB_MOVE)
elif initial.get("command"):
# "custom" kind, 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.tabs.setCurrentIndex(_TAB_OTHER)
else:
logger.warning(f"Unknown kind: {kind}")
def _on_accept(self):
try:
result = self._collect_result()
except ValueError as exc:
QMessageBox.warning(self, "Missing input", str(exc))
return
self._result = result
self.accept()
def _collect_result(self) -> dict:
current = self.tabs.currentIndex()
if current == _TAB_SCAN:
return self._collect_scan_result()
if current == _TAB_MOVE:
return self._collect_move_result()
if current == _TAB_DIGITAL_TWIN:
return self._collect_digital_twin_result()
return self._collect_custom_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,61 @@
from bec_widgets.utils.colors import get_accent_colors
from qtpy.QtCore import Qt, Signal
# pylint: disable=E0611
from qtpy.QtGui import QKeySequence
from qtpy.QtWidgets import QListWidget, QPushButton
class MyListWidget(QListWidget):
deletePressed = Signal()
emptySpaceClicked = Signal()
copyPressed = Signal()
pastePressed = Signal()
def keyPressEvent(self, event):
if event.key() == Qt.Key_Delete and self.currentItem() is not None:
self.deletePressed.emit()
event.accept()
return
if event.matches(QKeySequence.StandardKey.Copy) and self.currentItem() is not None:
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 self.itemAt(event.position().toPoint()) is None:
self.clearSelection()
self.setCurrentRow(-1)
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,69 @@
"""
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
# 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,71 @@
"""
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
) -> 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}")
for item in items:
logger.info(f"item: {item}")
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()
@@ -1,3 +1,18 @@
#######################################
## 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 ##
###################################
+1 -1
View File
@@ -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
+15 -1
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 ##
###################################
@@ -408,4 +422,4 @@ sl2_gapy:
softwareTrigger: false
deviceTags:
- optics
- slits
- slits
+30 -6
View File
@@ -10,9 +10,13 @@ from ophyd import EpicsSignal, EpicsSignalRO
from ophyd_devices import CompareStatus, DeviceStatus
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
TIMEOUT_FOR_PV = 5
class AbsorberError(Exception):
"""Absorber specific exception"""
@@ -38,6 +42,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,6 +66,7 @@ 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)
@@ -63,12 +75,25 @@ class Absorber(PSIDeviceBase):
# 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"""
def open(self, force: bool = False) -> DeviceStatus | None:
"""Open the Absorber
Args:
force(bool): If needed, set bl status to enable and bl enable to ENABLE, defaults to False
"""
if force and self.device_manager.devices.get("bl_status", None) is None:
raise AbsorberError("bl_status is not in device config, thus cannot use force = True")
if self.status.get() == STATUS.CLOSED:
if force:
if self.device_manager.bl_status.get() == BlStatus.OFFLINE:
status = self.device_manager.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_open = CompareStatus(self.status, STATUS.OPEN, timeout=self.timeout_for_move)
status = status_open
status = CompareStatus(self.status, STATUS.OPEN, timeout=self.timeout_for_move)
return status
else:
return None
@@ -77,8 +102,7 @@ class Absorber(PSIDeviceBase):
"""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)
return status
else:
return None
+85
View File
@@ -0,0 +1,85 @@
"""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, DeviceStatus
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
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) -> DeviceStatus | 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)
return status
else:
return None
def close(self) -> DeviceStatus | 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)
return status
else:
return None
@@ -263,7 +263,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 +373,7 @@ class IonizationChamber2(IonizationChamber0):
}
hv_en = Dcpt(hv_en_signals)
class Pips(IonizationChamber0):
"""Pips, prefix should be 'X01DA-'."""
+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)
+6
View File
@@ -58,3 +58,9 @@ class EncoderFactors(int, enum.Enum):
X1 = 4
X2 = 5
X4 = 6
class EpicsMode(int, enum.Enum):
"""Mode when sending through EPICS"""
MEAN = 0
MAX = 0
+106
View File
@@ -0,0 +1,106 @@
"""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, DeviceStatus
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
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, **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, force: bool = False) -> DeviceStatus | None:
"""Open the Shutter
Args:
force(bool): If needed, set bl status to enable and bl enable to ENABLE, defaults to False
"""
if force and self.device_manager.devices.get("bl_status", None) is None:
raise OPPhotonShutterError(
"bl_status is not in device config, thus cannot use force = True"
)
if self.status.get() == STATUS.CLOSED:
if force:
if self.device_manager.bl_status.get() == BlStatus.OFFLINE:
status = self.device_manager.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)
return status
else:
return None
def close(self) -> DeviceStatus | 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)
return status
else:
return None
+18 -3
View File
@@ -37,7 +37,7 @@ class OpMode(int, enum.Enum):
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,13 +53,21 @@ 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(
@@ -149,6 +157,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,8 @@
import enum
class BlStatus(str, enum.Enum):
"""Beamline status enum"""
OFFLINE = 0
ATTENDED = 1
REMOTE = 2