WIP: widget development #105
No files matched your search
@@ -0,0 +1,396 @@
|
||||
"""Module to automatically set the gains for the selected amplifiers"""
|
||||
|
||||
import builtins
|
||||
import time
|
||||
from bisect import bisect_right
|
||||
|
||||
import xraydb
|
||||
from bec_lib import bec_logger
|
||||
|
||||
from ...devices.absorber import STATUS as ABS_STATUS
|
||||
from ...devices.eh_shutter import STATUS as EH_PH_STATUS
|
||||
from ...devices.ionization_chambers.ionization_chamber_enums import AmplifierEnable
|
||||
from ...devices.nidaq.nidaq_enums import EpicsMode, NidaqState
|
||||
from ...devices.op_shutter import STATUS as OP_PH_STATUS
|
||||
from .beamline import BeamlineId, get_beamline_id
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
EMIN = -200 # Minimum energy will be edge energy - EMIN
|
||||
EMAX = 1000 # Maximum energy will be edge energy + EMAX
|
||||
|
||||
MIN_RING_CURRENT = 1 # Minimum ring current to use auto-gain
|
||||
NOMINAL_RING_CURRENT = 400 # Nominal ring current of SLS2
|
||||
|
||||
TIMEOUT_PUT_PV = 5 # Timeout to set a PV
|
||||
|
||||
MONO_VELOCITY = 20 # Move velocity in deg/s (only X01DA)
|
||||
TIMEOUT_MONO_MOVE = 30 # Timeout to finish a movement on the mono
|
||||
|
||||
AVAILABLE_GAINS_X01DA = [1e6, 1e7, 5e7, 1e8, 1e9] # ascending order
|
||||
AVAILABLE_GAINS_X10DA = [1e6, 1e7, 1e8, 1e9, 1e10] # ascending order
|
||||
MIN_SIGNAL = 0.05 # Minimum signal to count as valid signal
|
||||
FULL_SCALE_V = 10.0 # NIDAQ AI full-scale range
|
||||
SAFETY_MARGIN = 0.9 # keep max signal under 90% of full scale
|
||||
|
||||
|
||||
def wait_for_signal(signal, value, timeout: float) -> None:
|
||||
"""Function to wait for a signal to reach a certain value.
|
||||
|
||||
Args:
|
||||
signal: Signal to read from
|
||||
value: Target value the signal should reach
|
||||
timeout(float): Timeout in seconds before a TimeoutError is raised
|
||||
|
||||
"""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
if signal.get() == value:
|
||||
return
|
||||
raise TimeoutError(f"Signal {signal} did not reach value {value} within {timeout} seconds.")
|
||||
|
||||
|
||||
class AutoGainError(Exception):
|
||||
"""AutoGain specific error"""
|
||||
|
||||
|
||||
class AutoGainCore:
|
||||
"""Module to automatically set the gains for the selected amplifiers"""
|
||||
|
||||
def __init__(self):
|
||||
dev = builtins.__dict__.get("dev")
|
||||
bec = builtins.__dict__.get("bec")
|
||||
if dev is None:
|
||||
raise AutoGainError("Did not get dev")
|
||||
if bec is None:
|
||||
raise AutoGainError("Did not get bec")
|
||||
self.dev = dev
|
||||
self.bec = bec
|
||||
self.beamline = get_beamline_id()
|
||||
if self.beamline == BeamlineId.X01DA:
|
||||
self.available_gains = AVAILABLE_GAINS_X01DA
|
||||
else:
|
||||
self.available_gains = AVAILABLE_GAINS_X10DA
|
||||
self.linked_channels = None
|
||||
self.prohibited_gains = {}
|
||||
|
||||
def allowed_gains(self, amplifier):
|
||||
"""Returns a list of gains which are allowed for the amplifier"""
|
||||
if amplifier in self.prohibited_gains:
|
||||
gains = self.available_gains.copy()
|
||||
for prohibited_gain in self.prohibited_gains[amplifier]:
|
||||
gains.remove(prohibited_gain)
|
||||
return gains
|
||||
return self.available_gains
|
||||
|
||||
def start(
|
||||
self,
|
||||
element: str,
|
||||
edge: str,
|
||||
amplifier: list[str] | None = None,
|
||||
link_ic0_ic1: bool = True,
|
||||
comp_ring_current: bool = True,
|
||||
prohibited_gains: dict | None = None,
|
||||
) -> dict:
|
||||
"""Start the auto-gain sequence. Measure the signals of the specified
|
||||
amplifiers and set the gains accordingly. Makes sure there is actually beam available.
|
||||
|
||||
Note:
|
||||
If 'ic0' and 'ic1' are in the amplifier argument, the same gain will be assigned
|
||||
to both amplifiers to get the best glitch removal during processing later.
|
||||
|
||||
Args:
|
||||
element(str): Element which defines the energy at which the gain will be set, e.g. 'Cu'
|
||||
edge(str): Corresponding edge, e.g. 'L1'
|
||||
amplifier(list[str]): Amplifiers where auto-gain should be applied to
|
||||
Defaults to all amplifiers ->
|
||||
X01DA = ['ic0', 'ic1', 'ic2', 'pips']
|
||||
X10DA = ['ic0', 'ic1', 'ic2']
|
||||
link_ic0_ic1(bool): Apply same gain to ic0 and ic1. Defaults to True
|
||||
comp_ring_current(bool): Respects the current ring current and calculates the gain(s)
|
||||
for a nominal ring current of 400 mA. Defaults to True
|
||||
prohibited_gains(dict): Gains which are not allowed to be used.
|
||||
E.g.: {'ic0': [1e6]}. Defaults to None
|
||||
|
||||
Raises:
|
||||
If NIDAQ is not in measurement mode
|
||||
If Ring current is below 5 mA
|
||||
If Absorber, OP Photon Shutter or EH Photon Shutter is closed
|
||||
If a bec scan is running
|
||||
If the energy for the supplied element/edge cannot be found
|
||||
If the energy is outside the movement range of the monochromator
|
||||
If a selected amplifier is switched off
|
||||
If the high voltage of a selected ionization chamber is not enabled or < 1000 V
|
||||
If the gas filling of a selected ionization chamber is not OK
|
||||
If the ring current drops to 0 mA during the measurement (beamdump)
|
||||
"""
|
||||
|
||||
if link_ic0_ic1:
|
||||
self.linked_channels = {"ic0", "ic1"}
|
||||
else:
|
||||
self.linked_channels = {}
|
||||
|
||||
if prohibited_gains is None:
|
||||
self.prohibited_gains = {}
|
||||
else:
|
||||
self.prohibited_gains = prohibited_gains
|
||||
|
||||
if amplifier is None:
|
||||
if self.beamline == BeamlineId.X01DA:
|
||||
amplifier = ["ic0", "ic1", "ic2", "pips"]
|
||||
else:
|
||||
amplifier = ["ic0", "ic1", "ic2"]
|
||||
|
||||
# Make sure NIDAQ is in standby mode
|
||||
if self.dev.nidaq.state.get() != NidaqState.STANDBY:
|
||||
raise AutoGainError("NIDAQ was not in Standby mode, cannot proceed.")
|
||||
|
||||
# Check for beam availability
|
||||
if self._get_ring_current() < MIN_RING_CURRENT:
|
||||
raise AutoGainError(f"Ring current is below {MIN_RING_CURRENT} mA")
|
||||
if self.dev.abs.status.get() != ABS_STATUS.OPEN:
|
||||
raise AutoGainError("Absorber is closed, no beam")
|
||||
if self.dev.op_sh.status.get() != OP_PH_STATUS.NOT_CLOSED:
|
||||
raise AutoGainError("OP Photon Shutter is closed, no beam")
|
||||
if self.dev.eh_sh.status.get() != EH_PH_STATUS.NOT_CLOSED:
|
||||
raise AutoGainError("EH Photon Shutter is closed, no beam")
|
||||
|
||||
# Check if no scan is running
|
||||
scan_id = self.bec.queue.scan_storage.current_scan_id
|
||||
if len(scan_id) > 0:
|
||||
raise AutoGainError(f"Scan with ID {scan_id} is currently running, cannot continue")
|
||||
|
||||
# Get edge energy
|
||||
energy = xraydb.xray_edge(element, edge, True)
|
||||
if energy is None:
|
||||
raise ValueError(f"Could not find edge energy for element/edge {element}/{edge}")
|
||||
emin = energy + EMIN
|
||||
emax = energy + EMAX
|
||||
|
||||
# Check range of mono (only X01DA)
|
||||
if self.beamline == BeamlineId.X01DA:
|
||||
low_limit = self.dev.mo1_bragg.low_lim.get()
|
||||
high_limit = self.dev.mo1_bragg.high_lim.get()
|
||||
if emin < low_limit or emax > high_limit:
|
||||
raise ValueError(
|
||||
f"Chosen element/edge {element}/{edge} with edge energy of {energy}"
|
||||
+ " is outside of accessible range of monochromator "
|
||||
+ f"{low_limit:.1f} eV - {high_limit:.1f} eV"
|
||||
)
|
||||
# X10DA energy axis (goniometer) does not have lo/hi limits
|
||||
|
||||
# Map amplifier names to their NIDAQ channels
|
||||
channel_map = {
|
||||
"ic0": {"signal": self.dev.nidaq.ai0, "self.dev": self.dev.ic0},
|
||||
"ic1": {"signal": self.dev.nidaq.ai2, "self.dev": self.dev.ic1},
|
||||
"ic2": {"signal": self.dev.nidaq.ai4, "self.dev": self.dev.ic2},
|
||||
"pips": {"signal": self.dev.nidaq.ai6, "self.dev": self.dev.pips},
|
||||
}
|
||||
active_channels = {name: ch for name, ch in channel_map.items() if name in amplifier}
|
||||
|
||||
# Tests only for X01DA:
|
||||
if self.beamline == BeamlineId.X01DA:
|
||||
# Check if amplifieres are switched on (only X01DA)
|
||||
for name, ch in active_channels.items():
|
||||
if ch["self.dev"].amp.cOnOff.get() != AmplifierEnable.ON:
|
||||
raise AutoGainError(f"Amplifier of self.device {name} is not enabled")
|
||||
|
||||
# Check high voltage on ionization chambers (only X01DA)
|
||||
for name, ch in active_channels.items():
|
||||
if name != "pips":
|
||||
if ch["self.dev"].hv_en.ena.get() != 1:
|
||||
raise AutoGainError(
|
||||
f"High voltage of ionization chamber {name} is not enabled"
|
||||
)
|
||||
if ch["self.dev"].hv.hv_v.get() < 1000:
|
||||
raise AutoGainError(f"HV voltage of ionization chamber {name} is < 1000")
|
||||
if ch["self.dev"].hv.grid_v.get() < 1000:
|
||||
raise AutoGainError(f"Grid voltage of ionization chamber {name} is < 1000")
|
||||
|
||||
# Check gas filling of ionization chambers (only X01DA)
|
||||
for name, ch in active_channels.items():
|
||||
if name != "pips":
|
||||
if ch["self.dev"].gmes.status.get() != 1:
|
||||
raise AutoGainError(f"Gas filling of ionization chamber {name} is not OK")
|
||||
|
||||
# Get initial monochromator position and velocity
|
||||
if self.beamline == BeamlineId.X01DA:
|
||||
init_pos = self.dev.mo1_bragg.position
|
||||
init_vel = self.dev.mo1_bragg.velocity.get()
|
||||
else:
|
||||
init_pos = self.dev.mo1_rotx_energy.get()
|
||||
init_vel = None
|
||||
|
||||
# Get initial gains
|
||||
for name, ch in active_channels.items():
|
||||
ch["init_gain"] = ch["self.dev"].amp.cGain_ENUM.get()
|
||||
|
||||
logger.info("All checks done, start preparing for measurement")
|
||||
|
||||
results = {}
|
||||
|
||||
try:
|
||||
logger.info(f"Move mono to start of {emin} eV")
|
||||
if self.beamline == BeamlineId.X01DA:
|
||||
self.dev.mo1_bragg.velocity.put(MONO_VELOCITY)
|
||||
wait_for_signal(self.dev.mo1_bragg.velocity, MONO_VELOCITY, timeout=TIMEOUT_PUT_PV)
|
||||
status = self.dev.mo1_bragg.move(emin)
|
||||
else:
|
||||
status = self.dev.mo1_rotx_energy.move(emin)
|
||||
status.wait(TIMEOUT_MONO_MOVE)
|
||||
|
||||
# Set NIDAQ to max mode
|
||||
self.dev.nidaq.epics_mode.put(EpicsMode.MAX)
|
||||
wait_for_signal(self.dev.nidaq.epics_mode, EpicsMode.MAX, timeout=TIMEOUT_PUT_PV)
|
||||
|
||||
# Set gains to lowest gain
|
||||
for name, ch in active_channels.items():
|
||||
lowest_gain = self.allowed_gains(name)[0]
|
||||
ch["self.dev"].set_gain(lowest_gain)
|
||||
ch["gain"] = lowest_gain
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
remeasure = True
|
||||
logger.info(f"Start measurement from {emin} eV to {emax} eV")
|
||||
while remeasure:
|
||||
# Rest max values of NIDAQ signals
|
||||
self.dev.nidaq.epics_max_reset.put(True)
|
||||
time.sleep(0.2)
|
||||
|
||||
# Create temporary storage for max signal per channel
|
||||
data = {name: 0 for name in active_channels}
|
||||
|
||||
# Measure current ring current
|
||||
ring_current_1 = self._get_ring_current()
|
||||
logger.info(f"Ring current right before measurement: {ring_current_1} mA")
|
||||
if ring_current_1 < MIN_RING_CURRENT:
|
||||
raise AutoGainError(
|
||||
f"Ring current dropped below {MIN_RING_CURRENT} mA right before measurement"
|
||||
)
|
||||
|
||||
# Scan range, recording the peak NIDAQ signal per channel
|
||||
if self.beamline == BeamlineId.X01DA:
|
||||
self.dev.mo1_bragg.move(emax).wait(timeout=TIMEOUT_MONO_MOVE)
|
||||
else:
|
||||
self.dev.mo1_rotx_energy.move(emax).wait(timeout=TIMEOUT_MONO_MOVE)
|
||||
for name, ch in active_channels.items():
|
||||
data[name] = max(data[name], ch["signal"].get())
|
||||
|
||||
# Measure current ring current again
|
||||
ring_current_2 = self._get_ring_current()
|
||||
logger.info(f"Ring current right after measurement: {ring_current_2} mA")
|
||||
if ring_current_2 < MIN_RING_CURRENT:
|
||||
raise AutoGainError(
|
||||
f"Ring current dropped below {MIN_RING_CURRENT} mA during measurement"
|
||||
)
|
||||
ring_current = (ring_current_1 + ring_current_2) / 2
|
||||
|
||||
# Move back to first monochromator position
|
||||
if self.beamline == BeamlineId.X01DA:
|
||||
status = self.dev.mo1_bragg.move(emin)
|
||||
else:
|
||||
status = self.dev.mo1_rotx_energy.move(emin)
|
||||
|
||||
# Choose gain per channel based on the max signal recorded during the scan
|
||||
remeasure = False
|
||||
processed = set()
|
||||
|
||||
for name, ch in active_channels.items():
|
||||
if name in processed:
|
||||
continue
|
||||
|
||||
# ic0/ic1 must always end up with the same gain -> treat whichever of
|
||||
# them are present (one or both) as a single group for this decision
|
||||
if name in self.linked_channels:
|
||||
group_names = [n for n in self.linked_channels if n in active_channels]
|
||||
else:
|
||||
group_names = [name]
|
||||
|
||||
group_signals = []
|
||||
for gname in group_names:
|
||||
gsig = data[gname]
|
||||
logger.info(f"Raw signal for device {gname} is {gsig} V")
|
||||
if comp_ring_current:
|
||||
gsig = gsig * NOMINAL_RING_CURRENT / ring_current
|
||||
logger.info(f"Compensate for ring current, new raw signal is {gsig} V")
|
||||
group_signals.append(gsig)
|
||||
|
||||
# worst case: the weakest signal drives "need more gain",
|
||||
# the strongest signal drives "don't saturate"
|
||||
raw_signal_min = min(group_signals)
|
||||
raw_signal_max = max(group_signals)
|
||||
gain_ref = ch[
|
||||
"gain"
|
||||
] # linked channels always share gain, so any member's value works
|
||||
label = "/".join(group_names)
|
||||
|
||||
if raw_signal_min < MIN_SIGNAL:
|
||||
logger.info(f"Raw signal for {label} is below {MIN_SIGNAL}")
|
||||
if gain_ref == self.allowed_gains(name)[-1]:
|
||||
logger.warning(
|
||||
f"Amplifier of {label} at highest gain {gain_ref} and still not"
|
||||
+ f" measured signal above {MIN_SIGNAL}"
|
||||
)
|
||||
else:
|
||||
next_gain = self.allowed_gains(name)[
|
||||
bisect_right(self.allowed_gains(name), gain_ref * 100) - 1
|
||||
]
|
||||
for gname in group_names:
|
||||
active_channels[gname]["self.dev"].set_gain(next_gain)
|
||||
active_channels[gname]["gain"] = next_gain
|
||||
time.sleep(0.5)
|
||||
logger.info(f"Setting gain of {label} to {next_gain:.0e} and remeasure")
|
||||
remeasure = True
|
||||
else:
|
||||
gain = max(
|
||||
(
|
||||
g
|
||||
for g in self.allowed_gains(name)
|
||||
if raw_signal_max / gain_ref * g <= FULL_SCALE_V * SAFETY_MARGIN
|
||||
),
|
||||
default=min(self.allowed_gains(name)),
|
||||
)
|
||||
for gname in group_names:
|
||||
active_channels[gname]["self.dev"].set_gain(gain)
|
||||
active_channels[gname]["gain"] = gain
|
||||
logger.info(f"Calculated final gain for {label} of {gain:.0e}")
|
||||
results[label] = {
|
||||
"gain": gain,
|
||||
"max_signal": raw_signal_max / gain_ref * gain,
|
||||
}
|
||||
|
||||
processed.update(group_names)
|
||||
|
||||
# Wait for mono to return to start position
|
||||
status.wait(TIMEOUT_MONO_MOVE)
|
||||
except Exception as e:
|
||||
logger.error("Error during measurement, reset gains to their initial values")
|
||||
for name, ch in active_channels.items():
|
||||
ch["self.dev"].amp.cGain_ENUM.put(ch["init_gain"])
|
||||
raise AutoGainError(f"During execution of auto-gain, an exception occured: {e}") from e
|
||||
|
||||
# Reset NIDAQ to mean mode
|
||||
self.dev.nidaq.epics_mode.put(EpicsMode.MEAN)
|
||||
wait_for_signal(self.dev.nidaq.epics_mode, EpicsMode.MEAN, timeout=TIMEOUT_PUT_PV)
|
||||
|
||||
# Wait for mono to move to initial position and reset velocity
|
||||
if self.beamline == BeamlineId.X01DA:
|
||||
status = self.dev.mo1_bragg.move(init_pos)
|
||||
status.wait(TIMEOUT_MONO_MOVE)
|
||||
self.dev.mo1_bragg.velocity.put(init_vel)
|
||||
wait_for_signal(self.dev.mo1_bragg.velocity, init_vel, timeout=TIMEOUT_PUT_PV)
|
||||
else:
|
||||
status = self.dev.mo1_rotx_energy.move(init_pos)
|
||||
status.wait(TIMEOUT_MONO_MOVE)
|
||||
|
||||
return results
|
||||
|
||||
def _get_ring_current(self) -> float:
|
||||
ring_current = 0
|
||||
retries = 0
|
||||
while ring_current == 0 and retries < 10:
|
||||
ring_current = self.dev.curr.get()
|
||||
retries += 1
|
||||
time.sleep(0.01)
|
||||
return ring_current
|
||||
+10
-13
@@ -1,12 +1,20 @@
|
||||
import socket
|
||||
from enum import Enum
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
from .types import BeamlineId
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BeamlineId(str, Enum):
|
||||
"""
|
||||
Identifier for supported beamlines.
|
||||
"""
|
||||
|
||||
X01DA = "x01da"
|
||||
X10DA = "x10da"
|
||||
|
||||
|
||||
def get_beamline_id() -> BeamlineId:
|
||||
"""
|
||||
Based on the bec servers hostname, tries to extract the beamline
|
||||
@@ -38,14 +46,3 @@ def get_beamline_id() -> BeamlineId:
|
||||
raise ValueError(f"Wrong selection {bl}")
|
||||
else:
|
||||
raise ValueError("Cannot open digital twin without a beamline")
|
||||
|
||||
|
||||
def get_parameters():
|
||||
beamline = get_beamline_id()
|
||||
if beamline == "x01da":
|
||||
from . import x01da_parameters as parameters
|
||||
elif beamline == "x10da":
|
||||
from . import x10da_parameters as parameters
|
||||
else:
|
||||
raise ValueError(f"Unknown beamline: {beamline}")
|
||||
return parameters
|
||||
@@ -0,0 +1,3 @@
|
||||
from .digital_twin_core import get_parameters
|
||||
|
||||
parameters = get_parameters()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,277 @@
|
||||
"""Move an axis through BEC"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
# pylint: disable=E0402
|
||||
from ....devices.absorber import STATUS as ABS_STATUS
|
||||
from ..beamline import BeamlineId
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class MotionOutcome:
|
||||
DONE = "done"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled" # stopped by the user, or aborted because another motor failed
|
||||
|
||||
|
||||
class MotionWorker:
|
||||
"""
|
||||
Executes motion on the specified motor and includes some safety during
|
||||
motion for certain motors. Pure Python / Qt-independent: reports
|
||||
progress via plain callables instead of Qt signals, so it can run on
|
||||
any thread (or be unit-tested without a QApplication).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
beamline: BeamlineId,
|
||||
dev,
|
||||
motor,
|
||||
target_pos: float,
|
||||
on_position_changed: Callable[[float], None] | None = None,
|
||||
on_error: Callable[[], None] | None = None,
|
||||
on_finished: Callable[[], None] | None = None,
|
||||
):
|
||||
self.beamline = beamline
|
||||
self.dev = dev
|
||||
self.motor = motor
|
||||
self._target = target_pos
|
||||
self._stop_flag = threading.Event()
|
||||
self.on_position_changed = on_position_changed
|
||||
self.on_error = on_error
|
||||
self.on_finished = on_finished
|
||||
self.last_exception: Exception | None = None
|
||||
|
||||
def _emit_position_changed(self, pos: float):
|
||||
if self.on_position_changed is not None:
|
||||
self.on_position_changed(pos)
|
||||
|
||||
def _emit_error(self):
|
||||
if self.on_error is not None:
|
||||
self.on_error()
|
||||
|
||||
def _emit_finished(self):
|
||||
if self.on_finished is not None:
|
||||
self.on_finished()
|
||||
|
||||
def stop(self):
|
||||
"""Sets the stop flag"""
|
||||
self._stop_flag.set()
|
||||
|
||||
def run(self):
|
||||
"""Prepares the movement based on the axis (motor)"""
|
||||
match self.motor:
|
||||
case (
|
||||
"sldi_gapx"
|
||||
| "sldi_gapy"
|
||||
| "sldi_centerx"
|
||||
| "sldi_centery"
|
||||
| "sl1_gapx"
|
||||
| "sl1_gapy"
|
||||
| "sl1_centerx"
|
||||
| "sl1_centery"
|
||||
| "bm1_try"
|
||||
| "sl2_gapx"
|
||||
| "sl2_gapy"
|
||||
| "sl2_centerx"
|
||||
| "sl2_centery"
|
||||
| "bm2_try"
|
||||
| "es0wi_try"
|
||||
| "es1_try"
|
||||
| "es2_try"
|
||||
| "ot_try"
|
||||
| "ot_rotx"
|
||||
| "ot_es1_trz"
|
||||
| "ot_es2_trz"
|
||||
| "es1ic0_try"
|
||||
| "es1ic1_try"
|
||||
| "es1ic2_try"
|
||||
):
|
||||
self.motion()
|
||||
case "cm_trx":
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[{"device": self.dev["cm_roty"], "abs_tol": 0.05}],
|
||||
)
|
||||
case "cm_roty":
|
||||
self.motion(
|
||||
abs_closed=True, surveyed_axes=[{"device": self.dev["cm_trx"], "abs_tol": 0.05}]
|
||||
)
|
||||
case "cm_try":
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[
|
||||
{"device": self.dev["cm_rotx"], "abs_tol": 0.05},
|
||||
{"device": self.dev["cm_rotz"], "abs_tol": 0.05},
|
||||
],
|
||||
)
|
||||
case "cm_rotx":
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[
|
||||
{"device": self.dev["cm_try"], "abs_tol": 0.05},
|
||||
{"device": self.dev["cm_rotz"], "abs_tol": 0.05},
|
||||
],
|
||||
)
|
||||
case "cm_rotz":
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[
|
||||
{"device": self.dev["cm_try"], "abs_tol": 0.05},
|
||||
{"device": self.dev["cm_rotx"], "abs_tol": 0.05},
|
||||
],
|
||||
)
|
||||
case "cm_bnd":
|
||||
if self.beamline == "x01da":
|
||||
p1 = (
|
||||
1 / (self.dev.cm_bnd_radius.read()["cm_bnd_radius"]["value"] * 1e3) + 0.0284
|
||||
) / 2e-6
|
||||
p2 = (1 / (self._target * 1e3) + 0.0284) / 2e-6
|
||||
else:
|
||||
p1 = 541900 / self.dev.cm_bnd_radius.read()["cm_bnd_radius"]["value"] - 32570
|
||||
p2 = 541900 / self._target - 32570
|
||||
self._target = p2 - p1
|
||||
self.motion(relative=True, rb={"device": self.dev["cm_bnd_radius"]})
|
||||
case "mo1_try" | "mo1_trx" | "mo1_roty":
|
||||
self.motion(abs_closed=True)
|
||||
case "mo1_bragg_angle":
|
||||
if self.beamline == "x01da":
|
||||
self.motion()
|
||||
else: # x10da needs to move goniometer
|
||||
self.motion(alias="mo1_rotx")
|
||||
case "fm_trx":
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[{"device": self.dev["fm_roty"], "abs_tol": 0.05}],
|
||||
)
|
||||
case "fm_roty":
|
||||
self.motion(
|
||||
abs_closed=True, surveyed_axes=[{"device": self.dev["fm_trx"], "abs_tol": 0.05}]
|
||||
)
|
||||
case "fm_try":
|
||||
if self.beamline == "x01da":
|
||||
abs_tol = 0.05
|
||||
else: # superxas mirror less stable thus needs higher tolerance
|
||||
abs_tol = 0.2
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[
|
||||
{"device": self.dev["fm_rotx"], "abs_tol": abs_tol},
|
||||
{"device": self.dev["fm_rotz"], "abs_tol": abs_tol},
|
||||
],
|
||||
)
|
||||
case "fm_rotx":
|
||||
if self.beamline == "x01da":
|
||||
abs_tol = 0.05
|
||||
else: # superxas mirror less stable thus needs higher tolerance
|
||||
abs_tol = 0.2
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[
|
||||
{"device": self.dev["fm_try"], "abs_tol": abs_tol},
|
||||
{"device": self.dev["fm_rotz"], "abs_tol": abs_tol},
|
||||
],
|
||||
)
|
||||
case "fm_rotz":
|
||||
if self.beamline == "x01da":
|
||||
abs_tol = 0.05
|
||||
else: # superxas mirror less stable thus needs higher tolerance
|
||||
abs_tol = 0.2
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[
|
||||
{"device": self.dev["fm_try"], "abs_tol": abs_tol},
|
||||
{"device": self.dev["fm_rotx"], "abs_tol": abs_tol},
|
||||
],
|
||||
)
|
||||
case "fm_bnd":
|
||||
if self.beamline == "x01da":
|
||||
p1 = (
|
||||
1 / (self.dev.fm_bnd_radius.read()["fm_bnd_radius"]["value"] * 1e3)
|
||||
+ 4.28e-5
|
||||
) / 1.84e-9
|
||||
p2 = (1 / (self._target * 1e3) + 4.28e-5) / 1.84e-9
|
||||
else:
|
||||
p1 = (
|
||||
593088.7 / self.dev.fm_bnd_radius.read()["fm_bnd_radius"]["value"]
|
||||
+ 26124.41
|
||||
)
|
||||
p2 = 593088.7 / self._target + 26124.41
|
||||
self._target = p2 - p1
|
||||
self.motion(relative=True, rb={"device": self.dev["fm_bnd_radius"]})
|
||||
case _:
|
||||
logger.warning(f"Motor {self.motor} not integrated in digital twin!")
|
||||
|
||||
def motion(
|
||||
self,
|
||||
abs_closed: bool = False,
|
||||
relative: bool = False,
|
||||
rb=None,
|
||||
surveyed_axes=None,
|
||||
alias=None,
|
||||
):
|
||||
"""
|
||||
Moves an axis while surverying a set of axes (if set).
|
||||
Example surveyed_axes:
|
||||
[{'device': bec_device_object, 'abs_tol': 0.1},]
|
||||
|
||||
Args:
|
||||
surveyed_axes (list): List of dictionaries of devices
|
||||
"""
|
||||
logger.info(f"Move axis {self.motor} to target {self._target}, move_relative={relative}")
|
||||
try:
|
||||
if alias:
|
||||
self.motor = alias
|
||||
if abs_closed:
|
||||
if self.dev.abs.status.get() == ABS_STATUS.OPEN:
|
||||
status = self.dev.abs.close(wait=True)
|
||||
if surveyed_axes is not None:
|
||||
for surv_ax in surveyed_axes:
|
||||
surv_ax["name"] = surv_ax["device"].dotted_name
|
||||
surv_ax["old_value"] = surv_ax["device"].read(cached=True)[surv_ax["name"]][
|
||||
"value"
|
||||
]
|
||||
if rb is not None:
|
||||
rb["name"] = rb["device"].dotted_name
|
||||
status = self.dev[self.motor].move(self._target, relative=relative)
|
||||
last_check = time.time()
|
||||
update_interval = 0.1
|
||||
while status.status == "RUNNING":
|
||||
now = time.time()
|
||||
if time.time() - last_check < update_interval:
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
last_check = now
|
||||
if self._stop_flag.is_set():
|
||||
self.dev[self.motor].stop()
|
||||
self._stop_flag.clear()
|
||||
if rb is not None:
|
||||
self._emit_position_changed(
|
||||
rb["device"].read(cached=False)[rb["name"]]["value"]
|
||||
)
|
||||
else:
|
||||
self._emit_position_changed(
|
||||
self.dev[self.motor].read(cached=False)[self.motor]["value"]
|
||||
)
|
||||
if surveyed_axes is not None:
|
||||
for surv_ax in surveyed_axes:
|
||||
fb = surv_ax["device"].read(cached=True)[surv_ax["name"]]["value"]
|
||||
if abs(fb - surv_ax["old_value"]) > surv_ax["abs_tol"]:
|
||||
self.dev[self.motor].stop()
|
||||
self.last_exception = RuntimeError(
|
||||
f"{surv_ax['name']} deviated beyond tolerance {surv_ax['abs_tol']} during {self.motor} move"
|
||||
)
|
||||
self._emit_error()
|
||||
self._emit_finished()
|
||||
return
|
||||
self._emit_finished()
|
||||
except Exception as e:
|
||||
logger.error(f"Error during movement of {self.motor}: {e}")
|
||||
self.last_exception = e
|
||||
self._emit_error()
|
||||
self._emit_finished()
|
||||
+8
-2
@@ -27,12 +27,15 @@ class ConfigDict(TypedDict):
|
||||
mo1_mode (str): MO1 mode.
|
||||
mo1_xtal (str): MO1 crystal.
|
||||
mo1_bragg (float): MO1 Bragg angle.
|
||||
mo1_roty (float): MO1 Azimuthal angle.
|
||||
fm_rotx (float): FM rotation x.
|
||||
fm_stripe (str): FM stripe name.
|
||||
fm_trx (float): FM translation x.
|
||||
fm_qy (float): FM qy value.
|
||||
fm_gain_height (int): FM gain height.
|
||||
smpl (float): Sample value.
|
||||
ot_es1_trz (float): Position of ES1 table.
|
||||
es1man_trz (float): Position of Sample Manipulator TRZ
|
||||
smpl_to_xrd (float): Sample to XRD detector distance
|
||||
"""
|
||||
|
||||
energy: float
|
||||
@@ -44,12 +47,15 @@ class ConfigDict(TypedDict):
|
||||
mo1_mode: str
|
||||
mo1_xtal: str
|
||||
mo1_bragg: float
|
||||
mo1_roty: float
|
||||
fm_rotx: float
|
||||
fm_stripe: str
|
||||
fm_trx: float
|
||||
fm_qy: None | float
|
||||
fm_gain_height: int
|
||||
smpl: float
|
||||
ot_es1_trz: float
|
||||
es1man_trz: float
|
||||
smpl_to_xrd: None | float
|
||||
|
||||
|
||||
class DataDict(TypedDict):
|
||||
+6
@@ -13,6 +13,12 @@ mo1_try:
|
||||
range: [[-30, -0.1], [0.1, 30]]
|
||||
offset: [-1.78, -1.78]
|
||||
|
||||
mo1_roty:
|
||||
modifier:
|
||||
axis: mo1_trx
|
||||
range: [[-30, -0.1], [0.1, 30]] # Si311, Si111
|
||||
offset: [-136.80017, -89.15503]
|
||||
|
||||
sl1_centery:
|
||||
offset: -1.2
|
||||
|
||||
+1
-1
@@ -320,4 +320,4 @@ vacuum_pipes = pipe(
|
||||
)
|
||||
|
||||
Walls = namedtuple("walls", ["start", "end", "height"])
|
||||
walls = Walls(start=[13999.30], end=[13999 + 75.5 + 30], height=[[-20, 25]])
|
||||
walls = Walls(start=[13999.30], end=[13999 + 75.5 + 30], height=[[-20, 25]]) # WBBS
|
||||
+6
@@ -11,6 +11,12 @@ mo1_trx:
|
||||
range: [[-30, -0.1], [0.1, 30]]
|
||||
offset: [-4.3, 0]
|
||||
|
||||
mo1_roty:
|
||||
modifier:
|
||||
axis: mo1_trx
|
||||
range: [[-30, -0.1], [0.1, 30]] # Si111, Si311
|
||||
offset: [-116.400, -137.605]
|
||||
|
||||
sl1_centery:
|
||||
offset: -55.54
|
||||
|
||||
+299
-296
@@ -1,296 +1,299 @@
|
||||
"""
|
||||
X10DA / SuperXAS Beamline Parameters.
|
||||
This file describes the parameter of each component of the SuperXAS beamline
|
||||
to be used for raytracing and geometrical calculations.
|
||||
"""
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
import numpy as np
|
||||
import xrt.backends.raycing.materials as rm
|
||||
|
||||
# XRT definitions
|
||||
filterBeryl = rm.Material("Be", rho=1.85, kind="plate") # pyright: ignore[reportArgumentType]
|
||||
filterDiamond = rm.Material("C", rho=3.52, kind="plate") # pyright: ignore[reportArgumentType]
|
||||
filterGraphite = rm.Material("C", rho=2.266, kind="plate") # pyright: ignore[reportArgumentType]
|
||||
|
||||
stripeSi = rm.Material("Si", rho=2.33) # pyright: ignore[reportArgumentType]
|
||||
stripePt = rm.Material("Pt", rho=21.45) # pyright: ignore[reportArgumentType]
|
||||
stripeRh = rm.Material("Rh", rho=12.41) # pyright: ignore[reportArgumentType]
|
||||
stripeCr = rm.Material("Cr", rho=7.14) # pyright: ignore[reportArgumentType]
|
||||
stripePyrex = rm.Material(
|
||||
"Si", rho=2.20
|
||||
) # Use Si as bare element and the density of SiO2 # pyright: ignore[reportArgumentType]
|
||||
|
||||
si111_1 = rm.CrystalSi(hkl=(1, 1, 1), tK=77) # first xtal surface
|
||||
si311_1 = rm.CrystalSi(hkl=(3, 1, 1), tK=77) # first xtal surface
|
||||
si333_1 = rm.CrystalSi(hkl=(3, 3, 3), tK=77) # first xtal surface
|
||||
si511_1 = rm.CrystalSi(hkl=(5, 1, 1), tK=77) # first xtal surface
|
||||
si111_2 = rm.CrystalSi(hkl=(1, 1, 1), tK=77) # second xtal surface
|
||||
si311_2 = rm.CrystalSi(hkl=(3, 1, 1), tK=77) # second xtal surface
|
||||
si333_2 = rm.CrystalSi(hkl=(3, 3, 3), tK=77) # second xtal surface
|
||||
si511_2 = rm.CrystalSi(hkl=(5, 1, 1), tK=77) # second xtal surface
|
||||
|
||||
filterDiamond = rm.Material("C", rho=3.52, kind="plate") # pyright: ignore[reportArgumentType]
|
||||
filterBe = rm.Material("Be", rho=1.85, kind="plate") # pyright: ignore[reportArgumentType]
|
||||
filterSi3N4 = rm.Material(
|
||||
["Si", "N"], quantities=[3, 4], rho=3.44, kind="plate"
|
||||
) # pyright: ignore[reportArgumentType]
|
||||
filterAl = rm.Material("Al", rho=2.69, kind="plate") # pyright: ignore[reportArgumentType]
|
||||
filterGraphite = rm.Material("C", rho=2.266, kind="plate") # pyright: ignore[reportArgumentType]
|
||||
|
||||
# General parameters
|
||||
sourceHeight = 0
|
||||
|
||||
# Synchrotron
|
||||
synchrotron = namedtuple(
|
||||
"synchrotron", ["eE", "eI", "eEspread", "eEpsilonX", "eEpsilonZ", "betaX", "betaZ"]
|
||||
)
|
||||
|
||||
sls1 = synchrotron(
|
||||
eE=2.4, eI=0.4, eEspread=0.878e-3, eEpsilonX=5.63, eEpsilonZ=0.007, betaX=0.45, betaZ=14.4
|
||||
)
|
||||
|
||||
sls2 = synchrotron(
|
||||
eE=2.7, eI=0.4, eEspread=1.147e-3, eEpsilonX=0.156, eEpsilonZ=0.01, betaX=0.18, betaZ=4.6
|
||||
)
|
||||
|
||||
# Source
|
||||
bendingMagnet = namedtuple("bendingMagnet", ["name", "center", "sync", "B0"])
|
||||
|
||||
sls1_14t = bendingMagnet(name="FE-BM-SLS1-1.4T", center=(0, 0, 0), sync=sls1, B0=1.4)
|
||||
|
||||
sls2_21t = bendingMagnet(name="FE-BM-SLS2-2.1T", center=(0, 0, 0), sync=sls2, B0=2.1)
|
||||
|
||||
sls2_35t = bendingMagnet(name="FE-BM-SLS2-3.5T", center=(0, 0, 0), sync=sls2, B0=3.5)
|
||||
|
||||
sls2_50t = bendingMagnet(name="FE-BM-SLS2-5.0T", center=(0, 0, 0), sync=sls2, B0=5.0)
|
||||
|
||||
# FE slits
|
||||
fe_slits = namedtuple("slits", ["name", "center", "center1", "center2", "maxDivH", "maxDivV"])
|
||||
|
||||
feSlits = fe_slits(
|
||||
name="FE-SLITS",
|
||||
center=(0, 6117, sourceHeight),
|
||||
center1=(0, 5038.4, sourceHeight),
|
||||
center2=(0, 5282.9, sourceHeight),
|
||||
maxDivH=1.8e-3,
|
||||
maxDivV=0.8e-3,
|
||||
)
|
||||
|
||||
# Filters
|
||||
filt = namedtuple(
|
||||
"filt", ["name", "center", "pitch", "limPhysX", "limPhysY", "surface", "material", "thickness"]
|
||||
)
|
||||
|
||||
feWindow = filt(
|
||||
name="FE-WINDOW",
|
||||
center=(0.0, 6158, sourceHeight),
|
||||
pitch=np.pi / 2,
|
||||
limPhysX=(-6, 6),
|
||||
limPhysY=(-3.0, 3.0),
|
||||
surface="None",
|
||||
material=filterDiamond,
|
||||
thickness=0.1,
|
||||
)
|
||||
feWindow = feWindow._replace(
|
||||
surface="CVD Diamond window {0:0.0f} $\\mu$m".format(feWindow.thickness * 1e3)
|
||||
)
|
||||
|
||||
feFilt = filt(
|
||||
name="FE-FI",
|
||||
center=(0.0, 6590, sourceHeight),
|
||||
pitch=np.pi / 2,
|
||||
limPhysX=(-15, 15),
|
||||
limPhysY=(-10, 10),
|
||||
surface="None",
|
||||
material=filterGraphite,
|
||||
thickness=0.25,
|
||||
)
|
||||
feFilt = feFilt._replace(surface="Graphite filter {0:0.0f} $\\mu$m".format(feFilt.thickness * 1e3))
|
||||
|
||||
# Collimating mirror
|
||||
collimatingMirror = namedtuple(
|
||||
"collimatingMirror",
|
||||
[
|
||||
"name",
|
||||
"center",
|
||||
"surface",
|
||||
"material",
|
||||
"limPhysX",
|
||||
"limPhysY",
|
||||
"limOptX",
|
||||
"limOptY",
|
||||
"R",
|
||||
"pitch",
|
||||
"jack1",
|
||||
"jack2",
|
||||
"jack3",
|
||||
"tx1",
|
||||
"tx2",
|
||||
],
|
||||
)
|
||||
|
||||
cm = collimatingMirror(
|
||||
name="FE-CM",
|
||||
center=[0, 7560.8, sourceHeight],
|
||||
surface=("Pt", "Si", "Rh"),
|
||||
material=(stripePt, stripeSi, stripeRh),
|
||||
limPhysX=(-30, 30),
|
||||
limPhysY=(-600, 600),
|
||||
limOptX=((-21, -0.5, 11), (-4, 9.5, 23)),
|
||||
limOptY=((-500, -500, -500), (500, 500, 500)),
|
||||
R=[3e6, 15e6],
|
||||
pitch=[1.4e-3, 4.5e-3],
|
||||
jack1=[0.0, 7210.0, 0.0], # Tripod X, Y, Z (global)
|
||||
jack2=[-210.0, 8310.0, 0.0],
|
||||
jack3=[210.0, 8310.0, 0.0],
|
||||
tx1=[0.0, -575.5], # X-Stage 1 [x, y] (local)
|
||||
tx2=[0.0, 575],
|
||||
) # X-Stage 2
|
||||
|
||||
apertures = namedtuple("apertures", ["name", "center", "opening"])
|
||||
|
||||
fePS = apertures(
|
||||
name="FE-PS", center=[0, 8760, sourceHeight], opening=[-39 / 2, 39 / 2, -10, 29]
|
||||
) # left, right, bottom, top
|
||||
|
||||
opWbBsBlock = apertures(
|
||||
name="OP-WB-BS-BLOCK", center=[0.0, 13606 - 135, sourceHeight], opening=[-18.0, 18.0, 42, 76]
|
||||
) # left, right, bottom, top
|
||||
|
||||
opSlits1 = apertures(
|
||||
name="OP-SLITS 1", center=[0, 14145 - 135, sourceHeight], opening=[-35 / 2, 35 / 2, 47.5, 82.5]
|
||||
)
|
||||
|
||||
# OP Beam Monitors
|
||||
op_bm = namedtuple("op_bm", ["name", "center"])
|
||||
|
||||
opBM1 = op_bm(name="OP Beam Monitor 1", center=(0, 14525 - 135, sourceHeight))
|
||||
|
||||
opBM2 = op_bm(name="OP Beam Monitor 2", center=(0, 17161.6 - 135, sourceHeight))
|
||||
|
||||
# Monochromator
|
||||
monochromator = namedtuple(
|
||||
"monochromator",
|
||||
[
|
||||
"name",
|
||||
"center",
|
||||
"xtal",
|
||||
"material1",
|
||||
"material2",
|
||||
"xtalWidth",
|
||||
"xtalOffsetX",
|
||||
"xtalLength1",
|
||||
"xtalLength2",
|
||||
"xtalGap",
|
||||
"rotOffset",
|
||||
"heightOffset",
|
||||
"braggLim",
|
||||
"jack1",
|
||||
"jack2",
|
||||
"jack3",
|
||||
"tx",
|
||||
],
|
||||
)
|
||||
|
||||
mo1 = monochromator(
|
||||
name="OP-CCM1",
|
||||
center=[0.0, 11670 - 135, sourceHeight],
|
||||
xtal=("Si311", "Si111"),
|
||||
material1=(si311_1, si111_1),
|
||||
material2=(si311_2, si111_2),
|
||||
xtalWidth=(20, 20),
|
||||
xtalOffsetX=(19.2, -19.2),
|
||||
xtalLength1=(60, 60),
|
||||
xtalLength2=(60, 60),
|
||||
xtalGap=(8, 8),
|
||||
rotOffset=6, # not sure what it is
|
||||
heightOffset=8.5, # not sure what it is
|
||||
braggLim=[4, 35],
|
||||
jack1=[0.0, 11350.0, 0.0], # Tripod not available!
|
||||
jack2=[-400.0, 12350.0, 0.0],
|
||||
jack3=[400.0, 12350.0, 0.0],
|
||||
tx=0.0,
|
||||
) # X-Stage [x]
|
||||
|
||||
# Focusing mirror
|
||||
focusingMirror = namedtuple(
|
||||
"focusingMirror",
|
||||
[
|
||||
"name",
|
||||
"center",
|
||||
"surfaceToroid",
|
||||
"materialToroid",
|
||||
"limPhysXToroid",
|
||||
"limPhysYToroid",
|
||||
"limOptXToroid",
|
||||
"limOptYToroid",
|
||||
"R",
|
||||
"pitch",
|
||||
"r",
|
||||
"xToroid",
|
||||
"hToroid",
|
||||
"jack1",
|
||||
"jack2",
|
||||
"jack3",
|
||||
"tx1",
|
||||
"tx2",
|
||||
],
|
||||
)
|
||||
|
||||
OFFSET_TRX = 46.8735
|
||||
|
||||
fm = focusingMirror(
|
||||
name="OP-FM",
|
||||
center=[0.0, 15580 - 135, sourceHeight],
|
||||
surfaceToroid=("Rh", "Pt"),
|
||||
materialToroid=(stripeRh, stripePt),
|
||||
limPhysXToroid=(-54.0, 54.0),
|
||||
limPhysYToroid=(-565.0, 565.0),
|
||||
limOptXToroid=(
|
||||
(43.388 + OFFSET_TRX, -4.865 + OFFSET_TRX),
|
||||
(4.865 + OFFSET_TRX, -40.882 + OFFSET_TRX),
|
||||
),
|
||||
limOptYToroid=((-500.0, -500.0), (500.0, 500.0)),
|
||||
R=[3e6, 15e6],
|
||||
pitch=[1.4e-3, 4.5e-3],
|
||||
r=[30, 20],
|
||||
xToroid=[24.126 + OFFSET_TRX, -22 + OFFSET_TRX], # offset in local x
|
||||
hToroid=[7.0, 11.3], # depth of the cylinder at x = xCylinder1 and x = xCylinder2.
|
||||
jack1=[0.0, 14980.0, 0.0],
|
||||
jack2=[-75.0, 16180.0, 0.0],
|
||||
jack3=[75.0, 16180.0, 0.0],
|
||||
tx1=[0.0, -575.0], # X-Stage 1 [x, y]
|
||||
tx2=[0.0, 575.0],
|
||||
) # X-Stage 2 [x, y]
|
||||
|
||||
# Entry wall experimental hutch: 21593 mm from source (SLS2)
|
||||
|
||||
# Exit window
|
||||
ehWindow = filt(
|
||||
name="EH-WINDOW",
|
||||
center=(0.0, 22063, sourceHeight),
|
||||
pitch=np.pi / 2,
|
||||
limPhysX=(-10.0, 10.0),
|
||||
limPhysY=(17.5, 92.5),
|
||||
surface="None",
|
||||
material=filterBe,
|
||||
thickness=0.25,
|
||||
)
|
||||
ehWindow = ehWindow._replace(
|
||||
surface="Beryllium window {0:0.0f} $\\mu$m".format(ehWindow.thickness * 1e3)
|
||||
)
|
||||
|
||||
# Sample
|
||||
sample = namedtuple("sample", ["name", "center"])
|
||||
|
||||
es1 = sample(name="ES1", center=[0, 23823, sourceHeight])
|
||||
es2 = sample(name="ES2", center=[0, 25843, sourceHeight])
|
||||
|
||||
# Ionization chambers
|
||||
ic = namedtuple("sample", ["name", "center"])
|
||||
|
||||
es1ic0 = ic(name="ES1 IC0", center=[0, 23633, sourceHeight])
|
||||
es1ic1 = ic(name="ES1 IC1", center=[0, 24383, sourceHeight])
|
||||
es1ic2 = ic(name="ES1 IC2", center=[0, 24723, sourceHeight])
|
||||
"""
|
||||
X10DA / SuperXAS Beamline Parameters.
|
||||
This file describes the parameter of each component of the SuperXAS beamline
|
||||
to be used for raytracing and geometrical calculations.
|
||||
"""
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
import numpy as np
|
||||
import xrt.backends.raycing.materials as rm
|
||||
|
||||
# XRT definitions
|
||||
filterBeryl = rm.Material("Be", rho=1.85, kind="plate") # pyright: ignore[reportArgumentType]
|
||||
filterDiamond = rm.Material("C", rho=3.52, kind="plate") # pyright: ignore[reportArgumentType]
|
||||
filterGraphite = rm.Material("C", rho=2.266, kind="plate") # pyright: ignore[reportArgumentType]
|
||||
|
||||
stripeSi = rm.Material("Si", rho=2.33) # pyright: ignore[reportArgumentType]
|
||||
stripePt = rm.Material("Pt", rho=21.45) # pyright: ignore[reportArgumentType]
|
||||
stripeRh = rm.Material("Rh", rho=12.41) # pyright: ignore[reportArgumentType]
|
||||
stripeCr = rm.Material("Cr", rho=7.14) # pyright: ignore[reportArgumentType]
|
||||
stripePyrex = rm.Material(
|
||||
"Si", rho=2.20
|
||||
) # Use Si as bare element and the density of SiO2 # pyright: ignore[reportArgumentType]
|
||||
|
||||
si111_1 = rm.CrystalSi(hkl=(1, 1, 1), tK=77) # first xtal surface
|
||||
si311_1 = rm.CrystalSi(hkl=(3, 1, 1), tK=77) # first xtal surface
|
||||
si333_1 = rm.CrystalSi(hkl=(3, 3, 3), tK=77) # first xtal surface
|
||||
si511_1 = rm.CrystalSi(hkl=(5, 1, 1), tK=77) # first xtal surface
|
||||
si111_2 = rm.CrystalSi(hkl=(1, 1, 1), tK=77) # second xtal surface
|
||||
si311_2 = rm.CrystalSi(hkl=(3, 1, 1), tK=77) # second xtal surface
|
||||
si333_2 = rm.CrystalSi(hkl=(3, 3, 3), tK=77) # second xtal surface
|
||||
si511_2 = rm.CrystalSi(hkl=(5, 1, 1), tK=77) # second xtal surface
|
||||
|
||||
filterDiamond = rm.Material("C", rho=3.52, kind="plate") # pyright: ignore[reportArgumentType]
|
||||
filterBe = rm.Material("Be", rho=1.85, kind="plate") # pyright: ignore[reportArgumentType]
|
||||
filterSi3N4 = rm.Material(
|
||||
["Si", "N"], quantities=[3, 4], rho=3.44, kind="plate"
|
||||
) # pyright: ignore[reportArgumentType]
|
||||
filterAl = rm.Material("Al", rho=2.69, kind="plate") # pyright: ignore[reportArgumentType]
|
||||
filterGraphite = rm.Material("C", rho=2.266, kind="plate") # pyright: ignore[reportArgumentType]
|
||||
|
||||
# General parameters
|
||||
sourceHeight = 0
|
||||
|
||||
# Synchrotron
|
||||
synchrotron = namedtuple(
|
||||
"synchrotron", ["eE", "eI", "eEspread", "eEpsilonX", "eEpsilonZ", "betaX", "betaZ"]
|
||||
)
|
||||
|
||||
sls1 = synchrotron(
|
||||
eE=2.4, eI=0.4, eEspread=0.878e-3, eEpsilonX=5.63, eEpsilonZ=0.007, betaX=0.45, betaZ=14.4
|
||||
)
|
||||
|
||||
sls2 = synchrotron(
|
||||
eE=2.7, eI=0.4, eEspread=1.147e-3, eEpsilonX=0.156, eEpsilonZ=0.01, betaX=0.18, betaZ=4.6
|
||||
)
|
||||
|
||||
# Source
|
||||
bendingMagnet = namedtuple("bendingMagnet", ["name", "center", "sync", "B0"])
|
||||
|
||||
sls1_14t = bendingMagnet(name="FE-BM-SLS1-1.4T", center=(0, 0, 0), sync=sls1, B0=1.4)
|
||||
|
||||
sls2_21t = bendingMagnet(name="FE-BM-SLS2-2.1T", center=(0, 0, 0), sync=sls2, B0=2.1)
|
||||
|
||||
sls2_35t = bendingMagnet(name="FE-BM-SLS2-3.5T", center=(0, 0, 0), sync=sls2, B0=3.5)
|
||||
|
||||
sls2_50t = bendingMagnet(name="FE-BM-SLS2-5.0T", center=(0, 0, 0), sync=sls2, B0=5.0)
|
||||
|
||||
# FE slits
|
||||
fe_slits = namedtuple("slits", ["name", "center", "center1", "center2", "maxDivH", "maxDivV"])
|
||||
|
||||
feSlits = fe_slits(
|
||||
name="FE-SLITS",
|
||||
center=(0, 6117, sourceHeight),
|
||||
center1=(0, 5038.4, sourceHeight),
|
||||
center2=(0, 5282.9, sourceHeight),
|
||||
maxDivH=1.8e-3,
|
||||
maxDivV=0.8e-3,
|
||||
)
|
||||
|
||||
# Filters
|
||||
filt = namedtuple(
|
||||
"filt", ["name", "center", "pitch", "limPhysX", "limPhysY", "surface", "material", "thickness"]
|
||||
)
|
||||
|
||||
feWindow = filt(
|
||||
name="FE-WINDOW",
|
||||
center=(0.0, 6158, sourceHeight),
|
||||
pitch=np.pi / 2,
|
||||
limPhysX=(-6, 6),
|
||||
limPhysY=(-3.0, 3.0),
|
||||
surface="None",
|
||||
material=filterDiamond,
|
||||
thickness=0.1,
|
||||
)
|
||||
feWindow = feWindow._replace(
|
||||
surface="CVD Diamond window {0:0.0f} $\\mu$m".format(feWindow.thickness * 1e3)
|
||||
)
|
||||
|
||||
feFilt = filt(
|
||||
name="FE-FI",
|
||||
center=(0.0, 6590, sourceHeight),
|
||||
pitch=np.pi / 2,
|
||||
limPhysX=(-15, 15),
|
||||
limPhysY=(-10, 10),
|
||||
surface="None",
|
||||
material=filterGraphite,
|
||||
thickness=0.25,
|
||||
)
|
||||
feFilt = feFilt._replace(surface="Graphite filter {0:0.0f} $\\mu$m".format(feFilt.thickness * 1e3))
|
||||
|
||||
# Collimating mirror
|
||||
collimatingMirror = namedtuple(
|
||||
"collimatingMirror",
|
||||
[
|
||||
"name",
|
||||
"center",
|
||||
"surface",
|
||||
"material",
|
||||
"limPhysX",
|
||||
"limPhysY",
|
||||
"limOptX",
|
||||
"limOptY",
|
||||
"R",
|
||||
"pitch",
|
||||
"jack1",
|
||||
"jack2",
|
||||
"jack3",
|
||||
"tx1",
|
||||
"tx2",
|
||||
],
|
||||
)
|
||||
|
||||
cm = collimatingMirror(
|
||||
name="FE-CM",
|
||||
center=[0, 7560.8, sourceHeight],
|
||||
surface=("Pt", "Si", "Rh"),
|
||||
material=(stripePt, stripeSi, stripeRh),
|
||||
limPhysX=(-30, 30),
|
||||
limPhysY=(-600, 600),
|
||||
limOptX=((-21, -0.5, 11), (-4, 9.5, 23)),
|
||||
limOptY=((-500, -500, -500), (500, 500, 500)),
|
||||
R=[3e6, 15e6],
|
||||
pitch=[1.4e-3, 4.5e-3],
|
||||
jack1=[0.0, 7210.0, 0.0], # Tripod X, Y, Z (global)
|
||||
jack2=[-210.0, 8310.0, 0.0],
|
||||
jack3=[210.0, 8310.0, 0.0],
|
||||
tx1=[0.0, -575.5], # X-Stage 1 [x, y] (local)
|
||||
tx2=[0.0, 575],
|
||||
) # X-Stage 2
|
||||
|
||||
apertures = namedtuple("apertures", ["name", "center", "opening"])
|
||||
|
||||
fePS = apertures(
|
||||
name="FE-PS", center=[0, 8760, sourceHeight], opening=[-39 / 2, 39 / 2, -10, 29]
|
||||
) # left, right, bottom, top
|
||||
|
||||
opWbBsBlock = apertures(
|
||||
name="OP-WB-BS-BLOCK", center=[0.0, 13606 - 135, sourceHeight], opening=[-18.0, 18.0, 42, 76]
|
||||
) # left, right, bottom, top
|
||||
|
||||
opSlits1 = apertures(
|
||||
name="OP-SLITS 1", center=[0, 14145 - 135, sourceHeight], opening=[-35 / 2, 35 / 2, 47.5, 82.5]
|
||||
)
|
||||
|
||||
# OP Beam Monitors
|
||||
op_bm = namedtuple("op_bm", ["name", "center"])
|
||||
|
||||
opBM1 = op_bm(name="OP Beam Monitor 1", center=(0, 14525 - 135, sourceHeight))
|
||||
|
||||
opBM2 = op_bm(name="OP Beam Monitor 2", center=(0, 17161.6 - 135, sourceHeight))
|
||||
|
||||
# Monochromator
|
||||
monochromator = namedtuple(
|
||||
"monochromator",
|
||||
[
|
||||
"name",
|
||||
"center",
|
||||
"xtal",
|
||||
"material1",
|
||||
"material2",
|
||||
"xtalWidth",
|
||||
"xtalOffsetX",
|
||||
"xtalLength1",
|
||||
"xtalLength2",
|
||||
"xtalGap",
|
||||
"rotOffset",
|
||||
"heightOffset",
|
||||
"braggLim",
|
||||
"jack1",
|
||||
"jack2",
|
||||
"jack3",
|
||||
"tx",
|
||||
],
|
||||
)
|
||||
|
||||
mo1 = monochromator(
|
||||
name="OP-CCM1",
|
||||
center=[0.0, 11670 - 135, sourceHeight],
|
||||
xtal=("Si311", "Si111"),
|
||||
material1=(si311_1, si111_1),
|
||||
material2=(si311_2, si111_2),
|
||||
xtalWidth=(20, 20),
|
||||
xtalOffsetX=(19.2, -19.2),
|
||||
xtalLength1=(60, 60),
|
||||
xtalLength2=(60, 60),
|
||||
xtalGap=(8, 8),
|
||||
rotOffset=6, # not sure what it is
|
||||
heightOffset=8.5, # not sure what it is
|
||||
braggLim=[4, 35],
|
||||
jack1=[0.0, 11350.0, 0.0], # Tripod not available!
|
||||
jack2=[-400.0, 12350.0, 0.0],
|
||||
jack3=[400.0, 12350.0, 0.0],
|
||||
tx=0.0,
|
||||
) # X-Stage [x]
|
||||
|
||||
# Focusing mirror
|
||||
focusingMirror = namedtuple(
|
||||
"focusingMirror",
|
||||
[
|
||||
"name",
|
||||
"center",
|
||||
"surfaceToroid",
|
||||
"materialToroid",
|
||||
"limPhysXToroid",
|
||||
"limPhysYToroid",
|
||||
"limOptXToroid",
|
||||
"limOptYToroid",
|
||||
"R",
|
||||
"pitch",
|
||||
"r",
|
||||
"xToroid",
|
||||
"hToroid",
|
||||
"jack1",
|
||||
"jack2",
|
||||
"jack3",
|
||||
"tx1",
|
||||
"tx2",
|
||||
],
|
||||
)
|
||||
|
||||
OFFSET_TRX = 46.8735
|
||||
|
||||
fm = focusingMirror(
|
||||
name="OP-FM",
|
||||
center=[0.0, 15580 - 135, sourceHeight],
|
||||
surfaceToroid=("Rh", "Pt"),
|
||||
materialToroid=(stripeRh, stripePt),
|
||||
limPhysXToroid=(-54.0, 54.0),
|
||||
limPhysYToroid=(-565.0, 565.0),
|
||||
limOptXToroid=(
|
||||
(43.388 + OFFSET_TRX, -4.865 + OFFSET_TRX),
|
||||
(4.865 + OFFSET_TRX, -40.882 + OFFSET_TRX),
|
||||
),
|
||||
limOptYToroid=((-500.0, -500.0), (500.0, 500.0)),
|
||||
R=[3e6, 15e6],
|
||||
pitch=[1.4e-3, 4.5e-3],
|
||||
r=[30, 20],
|
||||
xToroid=[24.126 + OFFSET_TRX, -22 + OFFSET_TRX], # offset in local x
|
||||
hToroid=[7.0, 11.3], # depth of the cylinder at x = xCylinder1 and x = xCylinder2.
|
||||
jack1=[0.0, 14980.0, 0.0],
|
||||
jack2=[-75.0, 16180.0, 0.0],
|
||||
jack3=[75.0, 16180.0, 0.0],
|
||||
tx1=[0.0, -575.0], # X-Stage 1 [x, y]
|
||||
tx2=[0.0, 575.0],
|
||||
) # X-Stage 2 [x, y]
|
||||
|
||||
# Entry wall experimental hutch: 21593 mm from source (SLS2)
|
||||
|
||||
# Exit window
|
||||
ehWindow = filt(
|
||||
name="EH-WINDOW",
|
||||
center=(0.0, 22063, sourceHeight),
|
||||
pitch=np.pi / 2,
|
||||
limPhysX=(-10.0, 10.0),
|
||||
limPhysY=(17.5, 92.5),
|
||||
surface="None",
|
||||
material=filterBe,
|
||||
thickness=0.25,
|
||||
)
|
||||
ehWindow = ehWindow._replace(
|
||||
surface="Beryllium window {0:0.0f} $\\mu$m".format(ehWindow.thickness * 1e3)
|
||||
)
|
||||
|
||||
# Sample
|
||||
sample = namedtuple("sample", ["name", "center"])
|
||||
|
||||
es1 = sample(name="ES1", center=[0, 23823, sourceHeight])
|
||||
es2 = sample(name="ES2", center=[0, 25843, sourceHeight])
|
||||
|
||||
# Ionization chambers
|
||||
ic = namedtuple("sample", ["name", "center"])
|
||||
|
||||
es1ic0 = ic(name="ES1 IC0", center=[0, 23633, sourceHeight])
|
||||
es1ic1 = ic(name="ES1 IC1", center=[0, 24383, sourceHeight])
|
||||
es1ic2 = ic(name="ES1 IC2", center=[0, 24723, sourceHeight])
|
||||
|
||||
Walls = namedtuple("walls", ["start", "end", "height"])
|
||||
walls = Walls(start=[13472], end=[13472 + 354], height=[[-20, 36]]) # WBBS
|
||||
@@ -1,82 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from debye_bec.devices.absorber import STATUS as ABS_STATUS
|
||||
|
||||
logger = bec_logger.logger
|
||||
# import builtins to avoid linter errors
|
||||
dev = builtins.__dict__.get("dev")
|
||||
|
||||
class MoveToLabelError(Exception):
|
||||
"""Exception for the MoveToLabel function"""
|
||||
|
||||
def move_to_label():
|
||||
"""
|
||||
Function to move several motors to a specific position defined in the label dict.
|
||||
"""
|
||||
|
||||
label = get_device_conditions(label="digitalTwin")
|
||||
|
||||
# Get absorber status and close if open
|
||||
logger.info("Check Frontend Absorber Status")
|
||||
abs_was_open = dev.abs.status.get() == ABS_STATUS.OPEN
|
||||
if abs_was_open:
|
||||
logger.info(" Close Frontend Absorber")
|
||||
status = dev.abs.close()
|
||||
status.wait()
|
||||
|
||||
# Move Frontend Slits
|
||||
logger.info("Move Frontend Slits into position")
|
||||
devices = ["sldi_centerx", "sldi_centery", "sldi_gapx", "sldi_gapy"]
|
||||
matches = {key: label[key] for key in devices if key in label}
|
||||
statuses = []
|
||||
for device in matches.values():
|
||||
statuses.append(device['device'].move(device['value']))
|
||||
for status in statuses:
|
||||
status.wait(timeout=30)
|
||||
|
||||
# Move Collimating mirror
|
||||
logger.info("Move Collimating Mirror into position")
|
||||
if "cm_rotx" in label: # pitch
|
||||
logger.info(" Move pitch into position")
|
||||
surveyed_movement(
|
||||
axis=label['cm_rotx'],
|
||||
surveyed_axes= [
|
||||
{'device': dev.cm_rotz, 'abs_tol': 0.1},
|
||||
]
|
||||
)
|
||||
|
||||
# Restore absorber position
|
||||
logger.info("Restore Frontend Absorber Status")
|
||||
if abs_was_open:
|
||||
status = dev.abs.open()
|
||||
status.wait()
|
||||
|
||||
|
||||
def surveyed_movement(axis, surveyed_axes):
|
||||
"""
|
||||
Moves an axis while surverying a set of axes.
|
||||
|
||||
Args:
|
||||
axis (DeviceCondition): Device condition
|
||||
surveyed_axes (list): List of dicts (same format as DeviceCondition)
|
||||
|
||||
Raises:
|
||||
If during movement of axis, one of the surveyed axes moves out of tolerance.
|
||||
"""
|
||||
|
||||
for surv_ax in surveyed_axes:
|
||||
surv_ax['old_value'] = surv_ax['device'].read()
|
||||
status = axis['device'].move(axis['value'])
|
||||
while status.status == 'RUNNING':
|
||||
for surv_ax in surveyed_axes:
|
||||
if abs(surv_ax['device'].read() - surv_ax['old_value']) > surv_ax['abs_tol']:
|
||||
axis['device'].stop()
|
||||
raise MoveToLabelError(
|
||||
f"During movement of {axis['device'].name}, {surv_ax['device'].name} " +
|
||||
f"started to move unexpectedly (old pos: {surv_ax['old_value']}, " +
|
||||
f"current pos: {surv_ax['device'].read()})"
|
||||
)
|
||||
@@ -34,3 +34,18 @@ to setup the prompts.
|
||||
"""
|
||||
|
||||
# pylint: disable=invalid-name, unused-import, import-error, undefined-variable, unused-variable, unused-argument, no-name-in-module
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
logger.info("Using the Debye startup script.")
|
||||
|
||||
from debye_bec.bec_ipython_client.plugins.auto_gain_core import AutoGainCore
|
||||
from debye_bec.bec_ipython_client.plugins.digital_twin_core.digital_twin_core import DigitalTwinCore
|
||||
|
||||
digital_twin = DigitalTwinCore()
|
||||
logger.success("Digital Twin Core loaded. Use 'digital_twin' to access it.")
|
||||
|
||||
auto_gain = AutoGainCore()
|
||||
logger.success("Auto-Gain module loaded. Use 'auto_gain' to access it.")
|
||||
File renamed without changes.
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Auto Gain: Custom BEC widget to automatically set gains.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
# pylint: disable=E0611
|
||||
from qtpy.QtCore import QObject, QThread, Signal
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from ....bec_ipython_client.plugins.auto_gain_core import AutoGainCore
|
||||
from ....bec_ipython_client.plugins.beamline import BeamlineId, get_beamline_id
|
||||
from ..edge_selector import EdgeSelector
|
||||
from .qt_widgets import CheckBox, Group, MatrixSelector, MyButton, TextIndicator
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class Worker(QObject):
|
||||
progress = Signal(str)
|
||||
completed = Signal(int)
|
||||
|
||||
@SafeSlot(dict)
|
||||
def start(self, config: dict):
|
||||
success = False
|
||||
try:
|
||||
core = AutoGainCore()
|
||||
self.progress.emit(f"AutoGain started with config {config}")
|
||||
results = core.start(
|
||||
config["element"],
|
||||
config["edge"],
|
||||
amplifier=config["all_amplifiers"],
|
||||
link_ic0_ic1=config["link_ic0_ic1"],
|
||||
comp_ring_current=config["compensate_ring_current"],
|
||||
prohibited_gains=config["prohibited_gains"],
|
||||
)
|
||||
lines = [
|
||||
f"{amp} set to gain {res['gain']:.0e}, max signal {res['max_signal']:.3f} V"
|
||||
for amp, res in results.items()
|
||||
]
|
||||
self.progress.emit("\n".join(lines))
|
||||
success = True
|
||||
except Exception as exc:
|
||||
logger.exception("Auto gain failed")
|
||||
self.progress.emit(f"Error: {exc}")
|
||||
finally:
|
||||
self.completed.emit(success)
|
||||
|
||||
|
||||
class AutoGain(BECWidget, QWidget):
|
||||
"""
|
||||
Main widget of auto gain widget.
|
||||
"""
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "tune"
|
||||
|
||||
start_worker = Signal(dict)
|
||||
|
||||
def __init__(self, show_control: bool = True, *arg, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, *arg, **kwargs)
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self.beamline = get_beamline_id()
|
||||
|
||||
central = QWidget()
|
||||
self.root_layout = QVBoxLayout(central)
|
||||
|
||||
self.edge_selector_button = MyButton("Choose", "default")
|
||||
self.edge_label = QLabel("No edge selected")
|
||||
self.edge_group = Group(
|
||||
"Edge selection", [self.edge_selector_button, self.edge_label], orientation="horizontal"
|
||||
)
|
||||
self.edge_element = None
|
||||
self.edge_edge = None
|
||||
self.edge_energy = None
|
||||
|
||||
self.root_layout.addWidget(self.edge_group)
|
||||
|
||||
self.ic0_check = CheckBox("ic0", "IC0")
|
||||
self.ic1_check = CheckBox("ic1", "IC1")
|
||||
self.ic2_check = CheckBox("ic2", "IC2")
|
||||
self.pips_check = CheckBox("pips", "PIPS")
|
||||
if self.beamline == BeamlineId.X01DA:
|
||||
self.apply_group = Group(
|
||||
"Apply auto gain on",
|
||||
[self.ic0_check, self.ic1_check, self.ic2_check, self.pips_check],
|
||||
)
|
||||
else: # PIPS of X10DA has fixed gain amplifier
|
||||
self.apply_group = Group(
|
||||
"Apply auto gain on", [self.ic0_check, self.ic1_check, self.ic2_check]
|
||||
)
|
||||
self.root_layout.addWidget(self.apply_group)
|
||||
|
||||
self.link_ic0_ic1 = CheckBox("link_ic0_ic1", "Link IC0/IC1 gains")
|
||||
|
||||
self.comp_ring_current = CheckBox("comp_ring_current", "Calculate for 400 mA ring current")
|
||||
|
||||
self.prohibited_gains = MatrixSelector("Prohibited gains", beamline=self.beamline)
|
||||
|
||||
self.expert_group = Group(
|
||||
"Expert settings", [self.link_ic0_ic1, self.comp_ring_current, self.prohibited_gains]
|
||||
)
|
||||
|
||||
self.root_layout.addWidget(self.expert_group)
|
||||
|
||||
self.start_button = MyButton("Start", "success")
|
||||
self.status = TextIndicator(label="Status")
|
||||
self.status.setText("Standby")
|
||||
self.status.setColor(get_accent_colors().default.name())
|
||||
|
||||
self.control_group = Group("Control", [self.start_button, self.status])
|
||||
|
||||
if show_control:
|
||||
self.root_layout.addWidget(self.control_group)
|
||||
|
||||
self.root_layout.addStretch()
|
||||
|
||||
self.setLayout(self.root_layout)
|
||||
self.setWindowTitle("Auto Gain")
|
||||
|
||||
self.edge_selector_button.clicked.connect(self._update_edge)
|
||||
self.start_button.clicked.connect(self._start)
|
||||
|
||||
self.worker = Worker()
|
||||
self.start_worker.connect(self.worker.start)
|
||||
self.worker_thread = QThread()
|
||||
self.worker.moveToThread(self.worker_thread)
|
||||
|
||||
self.worker.progress.connect(self._update_status)
|
||||
self.worker.completed.connect(self._complete)
|
||||
self.worker.completed.connect(self.worker_thread.quit)
|
||||
|
||||
def _update_edge(self, *_):
|
||||
dlg = EdgeSelector(self)
|
||||
if dlg.exec_():
|
||||
self.edge_element = dlg.selected_element
|
||||
self.edge_edge = dlg.selected_edge
|
||||
self.edge_energy = dlg.selected_energy
|
||||
self.edge_label.setText(
|
||||
f"{dlg.selected_element}, {dlg.selected_edge}-edge, {dlg.selected_energy:0.1f} eV"
|
||||
)
|
||||
|
||||
def _start(self):
|
||||
if not self.worker_thread.isRunning():
|
||||
self.start_button.setEnabled(False)
|
||||
self.status.setColor(get_accent_colors().warning.name())
|
||||
self.worker_thread.start()
|
||||
self.start_worker.emit(self.get_config())
|
||||
|
||||
def _update_status(self, text):
|
||||
self.status.setText(text)
|
||||
|
||||
def _complete(self, *_):
|
||||
self.start_button.setEnabled(True)
|
||||
self.status.setColor(get_accent_colors().success.name())
|
||||
|
||||
def load_config(self, config: dict):
|
||||
self.edge_element = config["element"]
|
||||
self.edge_edge = config["edge"]
|
||||
self.edge_energy = config["energy"]
|
||||
self.ic0_check.setChecked(config["ic0"])
|
||||
self.ic1_check.setChecked(config["ic1"])
|
||||
self.ic2_check.setChecked(config["ic2"])
|
||||
self.pips_check.setChecked(config["pips"])
|
||||
self.link_ic0_ic1.setChecked(config["link_ic0_ic1"])
|
||||
self.comp_ring_current.setChecked(config["compensate_ring_current"])
|
||||
self.prohibited_gains.set_selection(config["prohibited_gains"])
|
||||
|
||||
def get_config(self) -> dict:
|
||||
if self.edge_element is None or self.edge_edge is None:
|
||||
raise ValueError("Choose an absorption edge first.")
|
||||
amplifier_checks = [
|
||||
(self.ic0_check, "ic0"),
|
||||
(self.ic1_check, "ic1"),
|
||||
(self.ic2_check, "ic2"),
|
||||
(self.pips_check, "pips"),
|
||||
]
|
||||
amplifiers = [name for check, name in amplifier_checks if check.isChecked()]
|
||||
if not amplifiers:
|
||||
raise ValueError("Select at least one amplifier for auto gain.")
|
||||
return {
|
||||
"element": self.edge_element,
|
||||
"edge": self.edge_edge,
|
||||
"energy": self.edge_energy,
|
||||
"ic0": self.ic0_check.isChecked(),
|
||||
"ic1": self.ic1_check.isChecked(),
|
||||
"ic2": self.ic2_check.isChecked(),
|
||||
"pips": self.pips_check.isChecked(),
|
||||
"all_amplifiers": amplifiers,
|
||||
"link_ic0_ic1": self.link_ic0_ic1.isChecked(),
|
||||
"compensate_ring_current": self.comp_ring_current.isChecked(),
|
||||
"prohibited_gains": self.prohibited_gains.selection(),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
dispatcher = BECDispatcher(gui_id="auto_gain")
|
||||
win = AutoGain()
|
||||
win.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['auto_gain.py']}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from .auto_gain import AutoGain
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='AutoGain' name='auto_gain'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class AutoGainPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = AutoGain(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(AutoGain.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "auto_gain"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "AutoGain"
|
||||
|
||||
def toolTip(self):
|
||||
return "AutoGain"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,195 @@
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from qtpy.QtCore import Qt
|
||||
|
||||
# pylint: disable=E0611
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ....bec_ipython_client.plugins.beamline import BeamlineId
|
||||
|
||||
LABEL_WIDTH = 118
|
||||
ROW_MARGINS = (4, 0, 4, 0)
|
||||
ROW_SPACING = 6
|
||||
|
||||
|
||||
class Group(QGroupBox):
|
||||
def __init__(self, label, objs, orientation="vertical"):
|
||||
super().__init__(label)
|
||||
if orientation == "vertical":
|
||||
self._layout = QVBoxLayout(self)
|
||||
elif orientation == "horizontal":
|
||||
self._layout = QHBoxLayout(self)
|
||||
else:
|
||||
raise ValueError(f"Orientation {orientation} is not supported!")
|
||||
for obj in objs:
|
||||
if isinstance(obj, QWidget):
|
||||
self._layout.addWidget(obj)
|
||||
elif isinstance(obj, QLayout):
|
||||
self._layout.addLayout(obj)
|
||||
|
||||
|
||||
class CheckBox(QWidget):
|
||||
def __init__(self, identifier="", label=""):
|
||||
super().__init__()
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(*ROW_MARGINS)
|
||||
layout.setSpacing(ROW_SPACING)
|
||||
self.identifier = identifier
|
||||
self.label = QLabel(label)
|
||||
self.label.setFixedWidth(LABEL_WIDTH)
|
||||
self.label.setWordWrap(True)
|
||||
layout.addWidget(self.label)
|
||||
self.checkbox = QCheckBox()
|
||||
layout.addWidget(self.checkbox)
|
||||
|
||||
def setChecked(self, set_checked: bool = True):
|
||||
self.checkbox.setChecked(set_checked)
|
||||
|
||||
def isChecked(self):
|
||||
return self.checkbox.isChecked()
|
||||
|
||||
|
||||
class MatrixSelector(QWidget):
|
||||
"""Checkbox matrix for selecting amplifier/value combinations.
|
||||
|
||||
Displays a grid with one row per amplifier (IC0, IC1, IC2, PIPS) and one
|
||||
column per value (1e6, 1e7, 5e7, 1e8, 1e9). Each cell is a checkbox, so any
|
||||
combination of rows and columns can be selected independently.
|
||||
|
||||
Use `selection()` to read the current state as a dict mapping the
|
||||
lowercase row name to a list of the selected column values (as floats),
|
||||
e.g. ``{"ic0": [1e6], "ic1": [1e6, 5e7]}``. Rows with no checked boxes are
|
||||
omitted.
|
||||
|
||||
Attributes:
|
||||
ROWS (list[str]): Row titles, in display order.
|
||||
COLS (list[str]): Column titles, in display order. These are also
|
||||
parsed with ``float()`` to produce the values returned by
|
||||
`selection()`.
|
||||
checks (dict[tuple[str, str], QCheckBox]): Checkboxes keyed by
|
||||
``(row, col)`` using the original title strings.
|
||||
|
||||
Example:
|
||||
>>> selector = MatrixSelector()
|
||||
>>> my_layout.addWidget(selector)
|
||||
>>> selector.selection()
|
||||
{'ic0': [1000000.0], 'ic1': [1000000.0, 50000000.0]}
|
||||
"""
|
||||
|
||||
ROWS_X01DA = ["IC0", "IC1", "IC2", "PIPS"]
|
||||
COLS_X01DA = ["1e6", "1e7", "5e7", "1e8", "1e9"]
|
||||
|
||||
ROWS_X10DA = ["IC0", "IC1", "IC2"]
|
||||
COLS_X10DA = ["1e6", "1e7", "1e8", "1e9", "1e10"]
|
||||
|
||||
def __init__(self, label: str, beamline: BeamlineId, parent=None):
|
||||
super().__init__(parent)
|
||||
if beamline == BeamlineId.X01DA:
|
||||
self.cols = self.COLS_X01DA
|
||||
self.rows = self.ROWS_X01DA
|
||||
else:
|
||||
self.cols = self.COLS_X10DA
|
||||
self.rows = self.ROWS_X10DA
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(*ROW_MARGINS)
|
||||
layout.setSpacing(ROW_SPACING)
|
||||
self.label = QLabel(label)
|
||||
self.label.setFixedWidth(LABEL_WIDTH)
|
||||
self.label.setWordWrap(True)
|
||||
layout.addWidget(self.label)
|
||||
|
||||
grid_layout = QGridLayout()
|
||||
|
||||
# Column headers
|
||||
for c, col in enumerate(self.cols, start=1):
|
||||
grid_layout.addWidget(QLabel(col), 0, c, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Row headers + checkboxes
|
||||
self.checks = {}
|
||||
for r, row in enumerate(self.rows, start=1):
|
||||
grid_layout.addWidget(QLabel(row), r, 0)
|
||||
for c, col in enumerate(self.cols, start=1):
|
||||
cb = QCheckBox()
|
||||
grid_layout.addWidget(cb, r, c, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
self.checks[(row, col)] = cb
|
||||
|
||||
layout.addLayout(grid_layout)
|
||||
layout.addStretch()
|
||||
|
||||
def selection(self):
|
||||
"""Return e.g. {"ic0": [1e6], "ic1": [1e6, 5e7]}; rows with no selection are omitted."""
|
||||
result = {}
|
||||
for row in self.rows:
|
||||
values = [float(col) for col in self.cols if self.checks[(row, col)].isChecked()]
|
||||
if values:
|
||||
result[row.lower()] = values
|
||||
return result
|
||||
|
||||
def set_selection(self, selection):
|
||||
"""Set the checkboxes from a dict as returned by `selection()`.
|
||||
|
||||
Rows not present in `selection` are cleared. Values that don't match
|
||||
any column are ignored.
|
||||
"""
|
||||
# Normalize keys to lowercase and values to floats for comparison
|
||||
wanted = {key.lower(): {float(v) for v in values} for key, values in selection.items()}
|
||||
for row in self.rows:
|
||||
row_values = wanted.get(row.lower(), set())
|
||||
for col in self.cols:
|
||||
cb = self.checks[(row, col)]
|
||||
cb.setChecked(float(col) in row_values)
|
||||
|
||||
|
||||
class MyButton(QPushButton):
|
||||
def __init__(self, text="", color="default", parent=None):
|
||||
self.color = color
|
||||
super().__init__(text, parent)
|
||||
self.apply_theme()
|
||||
|
||||
def apply_theme(self):
|
||||
if self.isEnabled():
|
||||
colors = get_accent_colors()
|
||||
color = getattr(colors, self.color).name()
|
||||
self.setStyleSheet(f"QPushButton {{ background-color: {color}; color: white; }}")
|
||||
else:
|
||||
self.setStyleSheet(
|
||||
"QPushButton {{background-color: rgb(120, 120, 120); color: white;}}"
|
||||
)
|
||||
|
||||
def setEnabled(self, enable: bool = True):
|
||||
super().setEnabled(enable)
|
||||
self.apply_theme()
|
||||
|
||||
|
||||
class TextIndicator(QWidget):
|
||||
def __init__(self, label):
|
||||
super().__init__()
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(*ROW_MARGINS)
|
||||
layout.setSpacing(ROW_SPACING)
|
||||
self.label = QLabel(label)
|
||||
self.label.setFixedWidth(LABEL_WIDTH)
|
||||
self.label.setWordWrap(True)
|
||||
layout.addWidget(self.label)
|
||||
self.text = QLabel("-")
|
||||
self.text.setAlignment(Qt.AlignTop) # type: ignore
|
||||
layout.addWidget(self.text)
|
||||
|
||||
def setLabel(self, label) -> None:
|
||||
self.label.setText(label)
|
||||
|
||||
def setText(self, text):
|
||||
self.text.setText(text)
|
||||
|
||||
def setColor(self, color: str):
|
||||
self.text.setStyleSheet(f"QLabel {{color:{color}}}")
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from .auto_gain_plugin import AutoGainPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(AutoGainPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -13,13 +13,39 @@ logger = bec_logger.logger
|
||||
|
||||
|
||||
_Widgets = {
|
||||
"AutoGain": "AutoGain",
|
||||
"DataViewer": "DataViewer",
|
||||
"DigitalTwin": "DigitalTwin",
|
||||
"RestartServer": "RestartServer",
|
||||
"ScanControlXAS": "ScanControlXAS",
|
||||
"Scheduler": "Scheduler",
|
||||
}
|
||||
|
||||
|
||||
class AutoGain(RPCBase):
|
||||
"""Main widget of auto gain widget."""
|
||||
|
||||
_IMPORT_MODULE = "debye_bec.bec_widgets.widgets.auto_gain.auto_gain"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class DataViewer(RPCBase):
|
||||
"""Main widget of Data Viewer"""
|
||||
|
||||
@@ -69,7 +95,7 @@ class DigitalTwin(RPCBase):
|
||||
|
||||
|
||||
class RestartServer(RPCBase):
|
||||
"""Main widget of server restart widget"""
|
||||
"""Main widget of server restart widget."""
|
||||
|
||||
_IMPORT_MODULE = "debye_bec.bec_widgets.widgets.restart_server.restart_server"
|
||||
|
||||
@@ -115,3 +141,104 @@ class ScanControlXAS(RPCBase):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
|
||||
class Scheduler(RPCBase):
|
||||
"""Schedule, persist and execute a sequence of BEC scan/device commands."""
|
||||
|
||||
_IMPORT_MODULE = "debye_bec.bec_widgets.widgets.scheduler.scheduler"
|
||||
|
||||
@rpc_call
|
||||
def run_schedule(self, first_item: "int" = 0):
|
||||
"""
|
||||
Run the schedule, continuing from wherever it last left off.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def abort_schedule(self):
|
||||
"""
|
||||
Request that the schedule stop after the current item.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def reset_schedule(self):
|
||||
"""
|
||||
Clear all execution state and start the schedule over from item 1.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_status(self) -> "dict":
|
||||
"""
|
||||
RPC-exposed: current schedule state, e.g. for another widget or a script.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def add_item(
|
||||
self,
|
||||
command: "str",
|
||||
index: "int | None" = None,
|
||||
kind: "str" = "custom",
|
||||
form_state: "dict | None" = None,
|
||||
pause_on_failure: "bool" = False,
|
||||
) -> "str":
|
||||
"""
|
||||
RPC-exposed: insert a new, PENDING command into the schedule.
|
||||
Safe to call while the schedule is running.
|
||||
|
||||
Args:
|
||||
command: command text, evaluated the same way as the existing
|
||||
items (against `scans`/`dev`) once the schedule runs.
|
||||
index: position to insert at (0 = first). Clamped so the item
|
||||
can never land before something already running or
|
||||
finished. Defaults to appending at the end.
|
||||
kind/form_state: optional structured description of how
|
||||
`command` was built (see `schedule_item.ScheduleItem`),
|
||||
used to reopen the Edit dialog pre-filled. Leave as
|
||||
defaults for a plain, hand-typed command.
|
||||
|
||||
Returns:
|
||||
The new item's item_id.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def edit_item(
|
||||
self,
|
||||
item_id: "str",
|
||||
command: "str",
|
||||
kind: "str" = "custom",
|
||||
form_state: "dict | None" = None,
|
||||
pause_on_failure: "bool" = True,
|
||||
):
|
||||
"""
|
||||
RPC-exposed: change the command of an item that has not started
|
||||
yet. Raises `RuntimeError` for an item that is already
|
||||
running/finished.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def delete_item(self, item_id: "str"):
|
||||
"""
|
||||
RPC-exposed: remove an item that has not started yet. Raises
|
||||
`RuntimeError` for an item that is already running/finished.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def move_item(self, item_id: "str", new_index: "int"):
|
||||
"""
|
||||
RPC-exposed: move an item that has not started yet to a new
|
||||
position (0 = first, but never before something already
|
||||
running/finished). Raises `RuntimeError` for an item that is
|
||||
already running/finished.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def move_item_up(self, item_id: "str"):
|
||||
"""
|
||||
RPC-exposed: swap an item with the one directly before it.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def move_item_down(self, item_id: "str"):
|
||||
"""
|
||||
RPC-exposed: swap an item with the one directly after it.
|
||||
"""
|
||||
@@ -55,6 +55,7 @@ class DataViewer(BECWidget, QWidget):
|
||||
self.current_row = 0
|
||||
|
||||
self.input.scan_sel.currentItemChanged_connect(self.scan_sel_changed)
|
||||
self.input.scan_sel.itemDoubleClicked_connect(self.scan_sel_double_click)
|
||||
self.input.load_button.clicked_connect(self.load_scan_from_history)
|
||||
self.input.load_from_folder_button.clicked_connect(self.load_scan_from_folder)
|
||||
self.viewer.unload_button.clicked_connect(self.unload_all_scans)
|
||||
@@ -77,6 +78,12 @@ class DataViewer(BECWidget, QWidget):
|
||||
"""Updates the current row value of the scan selection list"""
|
||||
self.current_row = kwargs["value"]().row()
|
||||
|
||||
@SafeSlot()
|
||||
def scan_sel_double_click(self, *_, **kwargs):
|
||||
"""Updates the current row value of the scan selection list and loads the scan"""
|
||||
self.current_row = kwargs["value"]().row()
|
||||
self.load_scan_from_history()
|
||||
|
||||
@SafeSlot()
|
||||
def open_in_file_manager(self, *_):
|
||||
"""Open the scan folder in the systems default file manager"""
|
||||
|
||||
@@ -190,11 +190,11 @@ class DataView(QWidget):
|
||||
|
||||
if is_2d:
|
||||
n_rows, _ = data.shape
|
||||
row_data = data[0].astype(np.float32)
|
||||
row_data = data[0].astype(np.float64)
|
||||
else:
|
||||
row_data = data.reshape(-1).astype(np.float32)
|
||||
row_data = data.reshape(-1).astype(np.float64)
|
||||
|
||||
x = np.arange(row_data.size, dtype=np.float32)
|
||||
x = np.arange(row_data.size, dtype=np.float64)
|
||||
|
||||
self.plot_widget = pg.PlotWidget()
|
||||
plot_item = self.plot_widget.getPlotItem()
|
||||
@@ -233,8 +233,8 @@ class DataView(QWidget):
|
||||
|
||||
def on_row_changed(row):
|
||||
current_row_label.setText(str(row))
|
||||
new_data = data[row].astype(np.float32)
|
||||
new_x = np.arange(new_data.size, dtype=np.float32)
|
||||
new_data = data[row].astype(np.float64)
|
||||
new_x = np.arange(new_data.size, dtype=np.float64)
|
||||
curve.setData(new_x, new_data)
|
||||
plot_item.enableAutoRange() # type: ignore[attr-defined]
|
||||
|
||||
@@ -382,7 +382,7 @@ class DataView(QWidget):
|
||||
for c in range(show_cols):
|
||||
val = flat[r, c]
|
||||
txt = (
|
||||
f"{val:.6g}"
|
||||
f"{val:.12g}"
|
||||
if is_float
|
||||
else f"{val:.4g}" if is_complex else str(val.decode()) if is_bytes else str(val)
|
||||
)
|
||||
|
||||
@@ -105,6 +105,17 @@ class ListWidget(QWidget):
|
||||
)
|
||||
)
|
||||
|
||||
def itemDoubleClicked_connect(self, func):
|
||||
"""Connect a function to Double Click event."""
|
||||
self.value.itemDoubleClicked.connect(
|
||||
partial(
|
||||
func,
|
||||
identifier=self.identifier,
|
||||
value_obj=self.value,
|
||||
value=lambda: self.value.currentIndex(),
|
||||
)
|
||||
)
|
||||
|
||||
def setDisabled(self, disable):
|
||||
self.value.setDisabled(disable)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
# pylint: skip-file
|
||||
|
||||
designer_plugins = {
|
||||
"AutoGain": ("debye_bec.bec_widgets.widgets.auto_gain.auto_gain", "AutoGain"),
|
||||
"DataViewer": ("debye_bec.bec_widgets.widgets.data_viewer.data_viewer", "DataViewer"),
|
||||
"DigitalTwin": ("debye_bec.bec_widgets.widgets.digital_twin.digital_twin", "DigitalTwin"),
|
||||
"RestartServer": (
|
||||
@@ -15,11 +16,14 @@ designer_plugins = {
|
||||
"debye_bec.bec_widgets.widgets.scan_control_xas.scan_control_xas",
|
||||
"ScanControlXAS",
|
||||
),
|
||||
"Scheduler": ("debye_bec.bec_widgets.widgets.scheduler.scheduler", "Scheduler"),
|
||||
}
|
||||
|
||||
widget_icons = {
|
||||
"AutoGain": "tune",
|
||||
"DataViewer": "find_in_page",
|
||||
"DigitalTwin": "lightbulb",
|
||||
"RestartServer": "restart_alt",
|
||||
"ScanControlXAS": "tune",
|
||||
"Scheduler": "assignment_add",
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
from .beamline import get_parameters
|
||||
|
||||
parameters = get_parameters()
|
||||
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
Find the azimuthal (phi) setting of a monochromator crystal by matching
|
||||
glitches detected in a real measured spectrum against a line list exported
|
||||
by XOP's MARE "spaghetti" tool.
|
||||
|
||||
Pipeline
|
||||
--------
|
||||
1. parse_xop_macro(path) -- read an XOP MARE-glitches .pro macro and
|
||||
extract, for every reflection, (hkl,
|
||||
alpha0, k, p2/4, fh). These four numbers
|
||||
fully determine that reflection's glitch
|
||||
energy as a function of azimuth phi via
|
||||
XOP's own formula:
|
||||
|
||||
lambda(phi) = 1 / sqrt((k/cos(phi-alpha0))^2 + p2/4)
|
||||
E(phi) = hc / lambda(phi)
|
||||
|
||||
2. detect_glitches(E, I) -- find dip positions in a real measured
|
||||
(energy, intensity) scan: smooth to get a
|
||||
baseline, subtract, find negative peaks
|
||||
in the residual.
|
||||
|
||||
3. find_azimuth(...) -- for a grid of candidate phi, compute every
|
||||
reflection's predicted glitch energy,
|
||||
score how well the detected dip energies
|
||||
line up with predicted (fh-weighted)
|
||||
lines, and return the best-fit phi.
|
||||
|
||||
Self-test at the bottom regenerates synthetic "measured" glitches at a
|
||||
known phi from the XOP line list itself and checks that find_azimuth
|
||||
recovers that phi -- a good sanity check before trusting it on real data.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import minimize_scalar
|
||||
from scipy.signal import find_peaks, savgol_filter
|
||||
|
||||
HC = 12398.42 # eV * Angstrom
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 1. Parse an XOP MARE-glitches macro
|
||||
# ----------------------------------------------------------------------
|
||||
def parse_xop_macro(path):
|
||||
"""
|
||||
Returns a list of dicts: {hkl: (h,k,l), alpha0: deg, k: float,
|
||||
p2_4: float, fh: float}, one per reflection line in the macro.
|
||||
|
||||
XOP prints one comment line per reflection with columns:
|
||||
h k l alpha0 k p2/4 th2 th2 th2 th3 th3 th3
|
||||
lambda lambda lambda BrgAngU BrgAngG fh
|
||||
(18 numeric columns). We parse those lines directly rather than the
|
||||
'y3 = 1D/sqrt(...)' code lines -- same numbers, simpler regex.
|
||||
|
||||
A handful of lines in real XOP output have a formatting bug where two
|
||||
columns run together with no separating space (e.g. an l=-1 column
|
||||
immediately followed by a tiny alpha0 like 8.5e-7, printed as
|
||||
'-18.53774E-007'). Those are detected (h,k,l must be integers) and
|
||||
skipped rather than silently mis-parsed.
|
||||
"""
|
||||
reflections = []
|
||||
skipped = 0
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.rstrip("\n")
|
||||
if not line.startswith(";"):
|
||||
continue
|
||||
tokens = line[1:].split()
|
||||
if len(tokens) != 18:
|
||||
if len(tokens) >= 4 and re.match(r"^-?\d+$", tokens[0]):
|
||||
skipped += (
|
||||
1 # looks like a corrupted data line (columns ran together)
|
||||
)
|
||||
continue # otherwise a header/comment/label line -- not data
|
||||
try:
|
||||
vals = [float(t) for t in tokens]
|
||||
except ValueError:
|
||||
continue # header row (e.g. "h k l alpha0 ...") -- not data
|
||||
h, k, l = vals[0], vals[1], vals[2]
|
||||
if not all(abs(x - round(x)) < 1e-6 for x in (h, k, l)):
|
||||
skipped += 1 # corrupted line (merged columns)
|
||||
continue
|
||||
alpha0, k_param, p2_4 = vals[3], vals[4], vals[5]
|
||||
fh = vals[-1]
|
||||
reflections.append(
|
||||
dict(
|
||||
hkl=(int(h), int(k), int(l)),
|
||||
alpha0=alpha0,
|
||||
k=k_param,
|
||||
p2_4=p2_4,
|
||||
fh=fh,
|
||||
)
|
||||
)
|
||||
print(
|
||||
f"parse_xop_macro: parsed {len(reflections)} reflections, "
|
||||
f"skipped {skipped} malformed line(s)"
|
||||
)
|
||||
return reflections
|
||||
|
||||
|
||||
def filter_reflections(reflections, max_h=None, max_k=None, max_l=None, min_fh=None):
|
||||
"""
|
||||
Return only the reflections with |h|<=max_h, |k|<=max_k, |l|<=max_l
|
||||
and fh>=min_fh. Any limit left as None means "no limit" on that axis.
|
||||
Useful for cutting a large XOP line list (hundreds of reflections)
|
||||
down to the strong, low-index ones that actually matter.
|
||||
"""
|
||||
out = []
|
||||
for r in reflections:
|
||||
h, k, l = r["hkl"]
|
||||
if max_h is not None and abs(h) > max_h:
|
||||
continue
|
||||
if max_k is not None and abs(k) > max_k:
|
||||
continue
|
||||
if max_l is not None and abs(l) > max_l:
|
||||
continue
|
||||
if min_fh is not None and r["fh"] < min_fh:
|
||||
continue
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 2. Predict glitch energies at a given azimuth from the parsed line list
|
||||
# ----------------------------------------------------------------------
|
||||
def predicted_energies(reflections, phi_deg, energy_range=None):
|
||||
"""
|
||||
Returns (E_pred, fh, hkl_list) for every reflection whose glitch falls
|
||||
at a finite, real energy at this azimuth (optionally restricted to
|
||||
energy_range = (Emin, Emax)).
|
||||
"""
|
||||
alpha0 = np.array([r["alpha0"] for r in reflections])
|
||||
kpar = np.array([r["k"] for r in reflections])
|
||||
p24 = np.array([r["p2_4"] for r in reflections])
|
||||
fh = np.array([r["fh"] for r in reflections])
|
||||
hkl = [r["hkl"] for r in reflections]
|
||||
|
||||
cosval = np.cos(np.radians(phi_deg - alpha0))
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
term = kpar / cosval
|
||||
E = HC * np.sqrt(term**2 + p24)
|
||||
|
||||
valid = np.isfinite(E)
|
||||
if energy_range is not None:
|
||||
lo, hi = energy_range
|
||||
valid &= (E >= lo) & (E <= hi)
|
||||
|
||||
idx = np.where(valid)[0]
|
||||
return E[idx], fh[idx], [hkl[i] for i in idx]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 3. Detect glitches (dips) in a real measured spectrum -- split into
|
||||
# reusable steps so a GUI can recompute/redraw each one live.
|
||||
# ----------------------------------------------------------------------
|
||||
def sanitize_window(smooth_window, smooth_order, n_points):
|
||||
"""Coerce a smoothing window into something savgol_filter will accept:
|
||||
odd, >= smooth_order + 2, and not larger than the data."""
|
||||
w = min(smooth_window, n_points - (1 - n_points % 2))
|
||||
if w % 2 == 0:
|
||||
w -= 1
|
||||
w = max(w, smooth_order + 2 if (smooth_order + 2) % 2 == 1 else smooth_order + 3)
|
||||
return w
|
||||
|
||||
|
||||
def compute_baseline(energy, intensity, smooth_window=51, smooth_order=3):
|
||||
"""
|
||||
Sorts by energy and returns (energy_sorted, intensity_sorted, baseline,
|
||||
residual). This is step 1 (smoothing) on its own, so a GUI can redraw
|
||||
the baseline/residual live as the window/order controls move.
|
||||
"""
|
||||
energy = np.asarray(energy, dtype=float)
|
||||
intensity = np.asarray(intensity, dtype=float)
|
||||
order = np.argsort(energy)
|
||||
energy, intensity = energy[order], intensity[order]
|
||||
w = sanitize_window(smooth_window, smooth_order, len(intensity))
|
||||
baseline = savgol_filter(intensity, w, smooth_order)
|
||||
residual = intensity - baseline
|
||||
return energy, intensity, baseline, residual
|
||||
|
||||
|
||||
def find_dips(energy, residual, prominence=None, prominence_sigma=3.0):
|
||||
"""
|
||||
Step 2 (peak finding) on its own, so a GUI can redraw just the
|
||||
detected-dip markers as the prominence control moves, without
|
||||
recomputing the baseline. Returns (glitch_energies, prominences,
|
||||
prominence_used).
|
||||
"""
|
||||
if prominence is None:
|
||||
prominence = prominence_sigma * np.std(residual)
|
||||
peaks, props = find_peaks(-residual, prominence=prominence)
|
||||
return energy[peaks], props["prominences"], prominence
|
||||
|
||||
|
||||
def detect_glitches(
|
||||
energy,
|
||||
intensity,
|
||||
smooth_window=51,
|
||||
smooth_order=3,
|
||||
prominence=None,
|
||||
prominence_sigma=3.0,
|
||||
):
|
||||
"""
|
||||
Convenience wrapper chaining compute_baseline + find_dips for
|
||||
non-interactive use (see find_crystal_azimuth). Returns
|
||||
(glitch_energies, prominences) as before.
|
||||
"""
|
||||
energy, intensity, baseline, residual = compute_baseline(
|
||||
energy, intensity, smooth_window, smooth_order
|
||||
)
|
||||
glitch_E, prom, _ = find_dips(energy, residual, prominence, prominence_sigma)
|
||||
return glitch_E, prom
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 4. Score a candidate azimuth against the detected glitches
|
||||
# ----------------------------------------------------------------------
|
||||
def azimuth_score(phi_deg, reflections, detected_energies, energy_range, sigma_eV=3.0):
|
||||
"""
|
||||
For each detected glitch energy, find the best-matching predicted
|
||||
line (weighted by its fh, discounted by a Gaussian in energy
|
||||
distance) and sum those best-match scores. High score = many detected
|
||||
dips coincide with real, strong predicted lines at this phi.
|
||||
"""
|
||||
E_pred, fh, _ = predicted_energies(reflections, phi_deg, energy_range)
|
||||
if len(E_pred) == 0 or len(detected_energies) == 0:
|
||||
return 0.0
|
||||
diffs = detected_energies[:, None] - E_pred[None, :]
|
||||
gauss = np.exp(-0.5 * (diffs / sigma_eV) ** 2)
|
||||
weighted = gauss * fh[None, :]
|
||||
return weighted.max(axis=1).sum()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 5. Main search: scan phi, then refine locally
|
||||
# ----------------------------------------------------------------------
|
||||
def find_azimuth(
|
||||
reflections, detected_energies, energy_range, phi_grid=None, sigma_eV=3.0
|
||||
):
|
||||
"""
|
||||
Returns (phi_best, score_best, phi_grid, scores) so the coarse scan
|
||||
can be plotted as a diagnostic (a clean single maximum is a good
|
||||
sign; multiple comparable peaks means the data is ambiguous, e.g. too
|
||||
few glitches in range, or a real point-group ambiguity).
|
||||
"""
|
||||
detected_energies = np.asarray(detected_energies, dtype=float)
|
||||
if phi_grid is None:
|
||||
phi_grid = np.arange(0.0, 180.0, 0.5)
|
||||
|
||||
scores = np.array(
|
||||
[
|
||||
azimuth_score(p, reflections, detected_energies, energy_range, sigma_eV)
|
||||
for p in phi_grid
|
||||
]
|
||||
)
|
||||
i_best = int(np.argmax(scores))
|
||||
phi_coarse = phi_grid[i_best]
|
||||
|
||||
res = minimize_scalar(
|
||||
lambda p: -azimuth_score(
|
||||
p, reflections, detected_energies, energy_range, sigma_eV
|
||||
),
|
||||
bounds=(phi_coarse - 0.1, phi_coarse + 0.1),
|
||||
method="bounded",
|
||||
)
|
||||
return res.x, -res.fun, phi_grid, scores
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 6. End-to-end convenience wrapper
|
||||
# ----------------------------------------------------------------------
|
||||
def find_crystal_azimuth(
|
||||
macro_path,
|
||||
energy,
|
||||
intensity,
|
||||
energy_range,
|
||||
smooth_window=51,
|
||||
smooth_order=3,
|
||||
prominence=None,
|
||||
sigma_eV=3.0,
|
||||
phi_grid=None,
|
||||
):
|
||||
"""
|
||||
One-call pipeline: parse the XOP macro, detect glitches in the
|
||||
measurement, and return the best-fit azimuth.
|
||||
"""
|
||||
reflections = parse_xop_macro(macro_path)
|
||||
glitch_E, glitch_prom = detect_glitches(
|
||||
energy, intensity, smooth_window, smooth_order, prominence
|
||||
)
|
||||
print(
|
||||
f"find_crystal_azimuth: detected {len(glitch_E)} candidate glitches "
|
||||
f"in [{energy_range[0]}, {energy_range[1]}] eV"
|
||||
)
|
||||
phi_best, score_best, phi_grid, scores = find_azimuth(
|
||||
reflections, glitch_E, energy_range, phi_grid, sigma_eV
|
||||
)
|
||||
return dict(
|
||||
phi_deg=phi_best,
|
||||
score=score_best,
|
||||
glitch_energies=glitch_E,
|
||||
glitch_prominences=glitch_prom,
|
||||
phi_grid=phi_grid,
|
||||
scores=scores,
|
||||
reflections=reflections,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Self-test: regenerate synthetic glitches at a KNOWN phi from the macro
|
||||
# itself, then check find_azimuth recovers it. No real measurement needed.
|
||||
# ----------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
macro_path = sys.argv[1] if len(sys.argv) > 1 else "sample_xop.pro"
|
||||
true_phi = float(sys.argv[2]) if len(sys.argv) > 2 else -20.0
|
||||
energy_range = (4000.0, 12000.0)
|
||||
|
||||
reflections = parse_xop_macro(macro_path)
|
||||
E_true, fh_true, hkl_true = predicted_energies(reflections, true_phi, energy_range)
|
||||
|
||||
# keep only reasonably strong lines, as a real measurement would only
|
||||
# show glitches with non-negligible structure factor
|
||||
keep = fh_true > np.percentile(fh_true, 50) if len(fh_true) else []
|
||||
synthetic_glitches = E_true[keep]
|
||||
print(
|
||||
f"Self-test: {len(synthetic_glitches)} synthetic glitches at true phi={true_phi}"
|
||||
)
|
||||
|
||||
phi_best, score_best, phi_grid, scores = find_azimuth(
|
||||
reflections, synthetic_glitches, energy_range
|
||||
)
|
||||
print(
|
||||
f"Self-test: recovered phi = {phi_best:.2f} deg "
|
||||
f"(true = {true_phi} deg), score = {score_best:.2f}"
|
||||
)
|
||||
@@ -1,297 +0,0 @@
|
||||
"""
|
||||
Calculates the positions of axes based on a beamline config
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from bec_lib import bec_logger
|
||||
|
||||
from .. import parameters as bl
|
||||
from ..types import BeamlineId, ConfigDict
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def calc_positions(beamline: BeamlineId, cfg: ConfigDict) -> dict[str, dict[str, float]]:
|
||||
"""
|
||||
Calculates the positions of axes based on a beamline config.
|
||||
|
||||
Args:
|
||||
cfg(ConfigDict): Dictionary with beamline config
|
||||
|
||||
Returns:
|
||||
dict[str, dict[str, float]]: Dictionary mapping device names to dictionaries
|
||||
containing a "value" key with the corresponding float value (position).
|
||||
"""
|
||||
|
||||
pos = {}
|
||||
|
||||
## FE slits
|
||||
trxr = -np.arctan(cfg["h_acc"]) * bl.feSlits.center1[1]
|
||||
trxw = (
|
||||
(np.arctan(cfg["h_acc"]) * bl.feSlits.center1[1])
|
||||
/ bl.feSlits.center1[1]
|
||||
* bl.feSlits.center2[1]
|
||||
)
|
||||
tryb = -np.arctan(cfg["v_acc"]) * bl.feSlits.center1[1]
|
||||
tryt = (
|
||||
(np.arctan(cfg["v_acc"]) * bl.feSlits.center1[1])
|
||||
/ bl.feSlits.center1[1]
|
||||
* bl.feSlits.center2[1]
|
||||
)
|
||||
|
||||
xgap = trxw - trxr
|
||||
ygap = tryt - tryb
|
||||
|
||||
pos["sldi_gapx"] = {"value": xgap}
|
||||
pos["sldi_gapy"] = {"value": ygap}
|
||||
|
||||
## Collimating Mirror
|
||||
obj_dist = bl.cm.center[1] # object distance
|
||||
beam_vs = 2 * obj_dist * np.tan(cfg["v_acc"]) # vertical size of beam after CM
|
||||
|
||||
# TRX
|
||||
if cfg["cm_stripe"] in bl.cm.surface:
|
||||
index = bl.cm.surface.index(cfg["cm_stripe"])
|
||||
else:
|
||||
raise ValueError(f"Requested stripe {cfg['cm_stripe']} not found in parameters!")
|
||||
cm_trx = -(bl.cm.limOptX[0][index] + bl.cm.limOptX[1][index]) / 2
|
||||
pos["cm_trx"] = {"value": cm_trx}
|
||||
|
||||
# TRY
|
||||
height = obj_dist * np.tan(cfg["v_acc"]) ** 2 * 1 / np.tan(cfg["cm_pitch"])
|
||||
pos["cm_try"] = {"value": height}
|
||||
|
||||
# Pitch
|
||||
pos["cm_rotx"] = {
|
||||
"value": -cfg["cm_pitch"] * 1e3
|
||||
} # invert and convert to mrad (same as EGU of rotx axis)
|
||||
|
||||
# Bending Radius
|
||||
radius = (
|
||||
2.0 * obj_dist / np.sin(cfg["cm_pitch"])
|
||||
) # Elements of modern X-ray Physics, page 108 ff.
|
||||
pos["cm_bnd_radius"] = {"value": radius * 1e-6} # Convert to km
|
||||
|
||||
## Monochromator
|
||||
if cfg["mo1_mode"] == "Monochromatic":
|
||||
# Add 2x CM pitch to the bragg angle
|
||||
bragg = cfg["mo1_bragg"]
|
||||
elif cfg["mo1_mode"] == "Pinkbeam":
|
||||
# Align xtal surfaces parallel to beam
|
||||
bragg = 0
|
||||
else:
|
||||
raise ValueError("Monochromator mode not supported")
|
||||
pos["mo1_bragg_angle"] = {"value": bragg / np.pi * 180} # Bragg angle in deg
|
||||
|
||||
# TRY, Height
|
||||
l = bl.mo1.xtalGap[0] / np.sin(cfg["mo1_bragg"])
|
||||
yhor = l * np.cos(2.0 * (cfg["mo1_bragg"] + cfg["cm_pitch"]))
|
||||
yver = yhor * np.tan(2.0 * cfg["cm_pitch"])
|
||||
|
||||
if cfg["mo1_mode"] == "Monochromatic":
|
||||
beam_offset_mo1 = (
|
||||
l * np.sin(2.0 * (cfg["mo1_bragg"] + cfg["cm_pitch"])) - yver
|
||||
) # Resultat ist korrekt!
|
||||
elif cfg["mo1_mode"] == "Pinkbeam":
|
||||
beam_offset_mo1 = 0
|
||||
else:
|
||||
raise ValueError("Monochromator mode not supported")
|
||||
|
||||
def csc(a):
|
||||
return 1 / np.sin(a)
|
||||
|
||||
def cot(a):
|
||||
return 1 / np.tan(a)
|
||||
|
||||
# calculate height of center of first crystal surface
|
||||
f = bl.mo1.rotOffset # rotation offset, mm
|
||||
d = bl.mo1.heightOffset # xtal height offset, mm
|
||||
c = d * csc(cfg["mo1_bragg"]) - f * cot(cfg["mo1_bragg"])
|
||||
|
||||
# Calculate height of center of rotation
|
||||
b = np.sqrt(
|
||||
d**2 * csc(cfg["mo1_bragg"]) ** 2
|
||||
- 2 * d * f * cot(cfg["mo1_bragg"]) * csc(cfg["mo1_bragg"])
|
||||
+ f**2 * cot(cfg["mo1_bragg"]) ** 2
|
||||
+ f**2
|
||||
)
|
||||
h = np.cos(np.pi / 2 - np.arctan(f / c) - cfg["mo1_bragg"] - 2 * cfg["cm_pitch"]) * b
|
||||
h2 = ((bl.mo1.center[1] - bl.cm.center[1]) - np.sqrt(b**2 - h**2)) * np.tan(2 * cfg["cm_pitch"])
|
||||
height_mo1_real = (
|
||||
h + h2
|
||||
) # per design, the height should not change if the pitch of the CM is not changed!
|
||||
if cfg["mo1_mode"] == "Monochromatic":
|
||||
pass
|
||||
elif cfg["mo1_mode"] == "Pinkbeam":
|
||||
height_mo1_real = (
|
||||
height_mo1_real - 13
|
||||
) # Move down to let beam pass between both crystal without touching copper cooler
|
||||
else:
|
||||
raise ValueError("Monochromator mode not supported")
|
||||
pos["mo1_try"] = {"value": height_mo1_real}
|
||||
|
||||
# TRX, Crystal selection
|
||||
if cfg["mo1_mode"] == "Monochromatic":
|
||||
xtal = cfg["mo1_xtal"].translate(
|
||||
str.maketrans("", "", "()")
|
||||
) # Remove brackets from xtal name to conform with parameters
|
||||
if xtal in bl.mo1.xtal:
|
||||
index = bl.mo1.xtal.index(xtal)
|
||||
else:
|
||||
raise ValueError(f"Requested xtal {xtal} not found in parameters!")
|
||||
pos["mo1_trx"] = {"value": bl.mo1.xtalOffsetX[index]}
|
||||
else:
|
||||
pos["mo1_trx"] = {"value": 0}
|
||||
|
||||
diag = bl.mo1.xtalGap[0] / np.sin(cfg["mo1_bragg"]) # Calculations for Mono
|
||||
dz = diag * np.cos(2 * (cfg["cm_pitch"] + cfg["mo1_bragg"]))
|
||||
|
||||
## Slits 1
|
||||
d = bl.opSlits1.center[1] - bl.cm.center[1] - dz
|
||||
sl1_beam_height = d * np.tan(2 * cfg["cm_pitch"]) + beam_offset_mo1
|
||||
pos["sl1_centery"] = {"value": sl1_beam_height}
|
||||
pos["sl1_gapy"] = {"value": beam_vs}
|
||||
|
||||
## Beam Monitor 1
|
||||
d = bl.opBM1.center[1] - bl.cm.center[1] - dz
|
||||
bm1_beam_height = d * np.tan(2 * cfg["cm_pitch"]) + beam_offset_mo1
|
||||
pos["bm1_try"] = {"value": bm1_beam_height}
|
||||
|
||||
## Focusing Mirror
|
||||
p = bl.fm.center[1]
|
||||
q = cfg["smpl"] - bl.fm.center[1]
|
||||
f = (p * q) / (p + q) # focal length
|
||||
|
||||
# Bender radius
|
||||
if cfg["fm_qy"] is None:
|
||||
radius = 2 * q / np.sin(cfg["fm_rotx"]) # ideal bending radius for focused beam
|
||||
else:
|
||||
radius = (
|
||||
2 * cfg["fm_qy"] / np.sin(cfg["fm_rotx"])
|
||||
) # ideal bending radius for unfocused beam
|
||||
pos["fm_bnd_radius"] = {"value": radius * 1e-6} # Convert to km
|
||||
|
||||
# Pitch
|
||||
d = bl.fm.center[1] - bl.cm.center[1] - dz
|
||||
fm_rotx = (
|
||||
2 * cfg["cm_pitch"] - cfg["fm_rotx"]
|
||||
) # calculate pitch in absolute values (according to horizontal plane)
|
||||
pos["fm_rotx"] = {
|
||||
"value": -fm_rotx * 1e3
|
||||
} # invert and convert to mrad (same as EGU of rotx axis)
|
||||
|
||||
if cfg["fm_stripe"] in ("Rh (toroid)", "Pt (toroid)"):
|
||||
|
||||
# TRY
|
||||
if cfg["fm_stripe"] == "Rh (toroid)":
|
||||
r = bl.fm.r[0]
|
||||
h_cyl = bl.fm.hToroid[0]
|
||||
else: # PT toroid
|
||||
r = bl.fm.r[1]
|
||||
h_cyl = bl.fm.hToroid[1]
|
||||
width_beam = 2 * bl.fm.center[1] * np.tan(cfg["h_acc"] * 1e-3)
|
||||
alpha = np.arccos(1 - width_beam**2 / (2 * r**2))
|
||||
h = r - (r * np.cos(alpha / 2))
|
||||
fm_beam_height = (d * np.tan(2 * cfg["cm_pitch"]) + beam_offset_mo1) * cfg["fm_gain_height"]
|
||||
fm_height = (d * np.tan(2 * cfg["cm_pitch"]) + beam_offset_mo1 - h_cyl + h / 2) * cfg[
|
||||
"fm_gain_height"
|
||||
]
|
||||
pos["fm_try"] = {"value": fm_height}
|
||||
|
||||
# TRX
|
||||
if cfg["fm_stripe"] == "Rh (toroid)":
|
||||
x_cyl = -bl.fm.xToroid[0]
|
||||
else:
|
||||
x_cyl = -bl.fm.xToroid[1]
|
||||
pos["fm_trx"] = {"value": x_cyl}
|
||||
|
||||
elif cfg["fm_stripe"] in ("Rh (flat)", "Pt (flat)"):
|
||||
|
||||
# TRY
|
||||
fm_height = (d * np.tan(2 * cfg["cm_pitch"]) + beam_offset_mo1) * cfg["fm_gain_height"]
|
||||
fm_beam_height = fm_height
|
||||
pos["fm_try"] = {"value": fm_height}
|
||||
|
||||
# TRX
|
||||
if cfg["fm_stripe"] == "Rh (flat)":
|
||||
x_flat = -bl.fm.xFlat[0]
|
||||
else:
|
||||
x_flat = -bl.fm.xFlat[1]
|
||||
pos["fm_trx"] = {"value": x_flat}
|
||||
|
||||
else:
|
||||
raise ValueError("FM Stripe selection not valid")
|
||||
|
||||
pos["fm_roty"] = {"value": 0}
|
||||
pos["fm_rotz"] = {"value": 0}
|
||||
|
||||
## Slits 2
|
||||
if hasattr(bl, "opSlits2"):
|
||||
d = bl.opSlits2.center[1] - bl.fm.center[1]
|
||||
sl2_beam_height = fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"]))
|
||||
pos["sl2_centery"] = {"value": sl2_beam_height}
|
||||
pos["sl2_gapy"] = {"value": beam_vs}
|
||||
|
||||
## Beam Monitor 2
|
||||
d = bl.opBM2.center[1] - bl.fm.center[1]
|
||||
bm2_beam_height = fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"]))
|
||||
pos["bm2_try"] = {"value": bm2_beam_height}
|
||||
|
||||
## Optical Table
|
||||
|
||||
if beamline == "x01da":
|
||||
# TRY
|
||||
d = bl.ehWindow.center[1] - bl.fm.center[1]
|
||||
ot_height = fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"]))
|
||||
pos["ot_try"] = {"value": ot_height}
|
||||
|
||||
# Pitch
|
||||
ot_pitch = -(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"])
|
||||
pos["ot_rotx"] = {"value": ot_pitch * 1e3}
|
||||
|
||||
# TRZ ES1
|
||||
ot_es1_trz = cfg["smpl"]
|
||||
pos["ot_es1_trz"] = {"value": ot_es1_trz}
|
||||
|
||||
# ES0 exit window
|
||||
pos["es0wi_try"] = {
|
||||
"value": 5
|
||||
} # At 5mm, the middle of the window is 500 mm from the table (neutral position)
|
||||
else:
|
||||
# Exit window height
|
||||
d = bl.ehWindow.center[1] - bl.fm.center[1]
|
||||
es0wi_try = fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"]))
|
||||
pos["es0wi_try"] = {"value": es0wi_try}
|
||||
|
||||
# ES1 table height
|
||||
d = bl.es1.center[1] - bl.fm.center[1]
|
||||
es1_try = fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"]))
|
||||
pos["es1_try"] = {"value": es1_try}
|
||||
|
||||
# IC0 height
|
||||
d = bl.es1ic0.center[1] - bl.fm.center[1]
|
||||
es1ic0_try = (
|
||||
fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"])) - es1_try
|
||||
)
|
||||
pos["es1ic0_try"] = {"value": es1ic0_try}
|
||||
|
||||
# IC1 height
|
||||
d = bl.es1ic1.center[1] - bl.fm.center[1]
|
||||
es1ic1_try = (
|
||||
fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"])) - es1_try
|
||||
)
|
||||
pos["es1ic1_try"] = {"value": es1ic1_try}
|
||||
|
||||
# IC2 height
|
||||
d = bl.es1ic2.center[1] - bl.fm.center[1]
|
||||
es1ic2_try = (
|
||||
fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"])) - es1_try
|
||||
)
|
||||
pos["es1ic2_try"] = {"value": es1ic2_try}
|
||||
|
||||
# ES2 table height
|
||||
d = bl.es2.center[1] - bl.fm.center[1]
|
||||
es2_try = fm_beam_height - d * np.tan(-(2 * cfg["cm_pitch"] - 2 * cfg["fm_rotx"]))
|
||||
pos["es2_try"] = {"value": es2_try}
|
||||
|
||||
return pos
|
||||
@@ -1,70 +0,0 @@
|
||||
"""
|
||||
Calculates the sideview coordinates based on a beamline config.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .. import parameters as bl
|
||||
from ..types import ConfigDict, DataDict
|
||||
|
||||
|
||||
def calc_sideview(cfg: ConfigDict) -> DataDict:
|
||||
"""
|
||||
Calculates the sideview coordinates based on a beamline config.
|
||||
|
||||
Args:
|
||||
cfg(ConfigDict): Dictionary with beamline config
|
||||
|
||||
Returns:
|
||||
DataDict: Sideview data
|
||||
"""
|
||||
|
||||
beam: DataDict = {"x": [], "y": []}
|
||||
|
||||
beam["x"] = []
|
||||
beam["y"] = []
|
||||
beam["x"].append(0) # Source
|
||||
beam["y"].append(bl.sourceHeight)
|
||||
beam["x"].append(bl.cm.center[1]) # CM
|
||||
beam["y"].append(bl.sourceHeight)
|
||||
if cfg["mo1_mode"] == "Monochromatic":
|
||||
diag = bl.mo1.xtalGap[0] / np.sin(cfg["mo1_bragg"]) # Calculations for Mono
|
||||
dy = diag * np.sin(2 * (cfg["cm_pitch"] + cfg["mo1_bragg"]))
|
||||
dz = diag * np.cos(2 * (cfg["cm_pitch"] + cfg["mo1_bragg"]))
|
||||
beam["x"].append(bl.mo1.center[1] - dz / 2) # Mono 1.1
|
||||
beam["y"].append(
|
||||
bl.sourceHeight
|
||||
+ np.tan(2 * cfg["cm_pitch"]) * (bl.mo1.center[1] - dz / 2 - bl.cm.center[1])
|
||||
)
|
||||
beam["x"].append(bl.mo1.center[1] + dz / 2) # Mono 1.2
|
||||
beam["y"].append(
|
||||
bl.sourceHeight
|
||||
+ np.tan(2 * cfg["cm_pitch"]) * (bl.mo1.center[1] - dz / 2 - bl.cm.center[1])
|
||||
+ dy
|
||||
)
|
||||
beam["x"].append(bl.fm.center[1]) # FM
|
||||
beam["y"].append(
|
||||
bl.sourceHeight
|
||||
+ np.tan(2 * cfg["cm_pitch"]) * (bl.fm.center[1] - bl.cm.center[1] - dz)
|
||||
+ dy
|
||||
)
|
||||
beam["x"].append(cfg["smpl"]) # Experiment
|
||||
beam["y"].append(
|
||||
bl.sourceHeight
|
||||
+ np.tan(2 * cfg["cm_pitch"]) * (bl.fm.center[1] - bl.cm.center[1] - dz)
|
||||
+ dy
|
||||
+ np.tan(2 * (cfg["cm_pitch"] - cfg["fm_rotx"])) * (cfg["smpl"] - bl.fm.center[1])
|
||||
)
|
||||
elif cfg["mo1_mode"] == "Pinkbeam":
|
||||
beam["x"].append(bl.fm.center[1]) # FM
|
||||
beam["y"].append(
|
||||
bl.sourceHeight + np.tan(2 * cfg["cm_pitch"]) * (bl.fm.center[1] - bl.cm.center[1])
|
||||
)
|
||||
beam["x"].append(cfg["smpl"]) # Experiment
|
||||
beam["y"].append(
|
||||
bl.sourceHeight
|
||||
+ np.tan(2 * cfg["cm_pitch"]) * (bl.fm.center[1] - bl.cm.center[1])
|
||||
+ np.tan(2 * (cfg["cm_pitch"] - cfg["fm_rotx"])) * (cfg["smpl"] - bl.fm.center[1])
|
||||
)
|
||||
|
||||
return beam
|
||||
@@ -1,159 +0,0 @@
|
||||
"""
|
||||
Calculates the surface coordinates based on a beamline config.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
from bec_lib import bec_logger
|
||||
|
||||
from .. import parameters as bl
|
||||
from ..types import ConfigDict, SurfaceDict
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def calc_surfaces(cfg: ConfigDict) -> SurfaceDict:
|
||||
"""
|
||||
Calculates the surface coordinates based on a beamline config.
|
||||
|
||||
Args:
|
||||
cfg(ConfigDict): Dictionary with beamline config
|
||||
|
||||
Returns:
|
||||
SurfaceDict: Surface data
|
||||
"""
|
||||
|
||||
out: SurfaceDict = {
|
||||
"cm": {"x": [], "y": []},
|
||||
"mo1_1": {"x": [], "y": []},
|
||||
"mo1_2": {"x": [], "y": []},
|
||||
"fm": {"x": [], "y": []},
|
||||
}
|
||||
|
||||
# Collimating mirror
|
||||
l = 2 * bl.cm.center[1] * np.tan(cfg["v_acc"]) / np.sin(cfg["cm_pitch"])
|
||||
|
||||
w1 = 2 * (bl.cm.center[1] - l / 2) * np.tan(cfg["h_acc"])
|
||||
w2 = 2 * (bl.cm.center[1] + l / 2) * np.tan(cfg["h_acc"])
|
||||
|
||||
# index = bl.cm.surface.index(cfg["cm_stripe"])
|
||||
|
||||
cen = -cfg["cm_trx"]
|
||||
|
||||
out["cm"]["x"] = [cen - w1 / 2, cen - w2 / 2, cen + w2 / 2, cen + w1 / 2]
|
||||
out["cm"]["y"] = [-l / 2, l / 2, l / 2, -l / 2]
|
||||
|
||||
# Monochromator
|
||||
# calculate height of center of first crystal surface
|
||||
c = bl.mo1.heightOffset * 1 / np.sin(cfg["mo1_bragg"]) - bl.mo1.rotOffset * 1 / np.tan(
|
||||
cfg["mo1_bragg"]
|
||||
)
|
||||
e = bl.mo1.xtalGap[0] / np.tan(cfg["mo1_bragg"]) - c
|
||||
|
||||
xtal = cfg["mo1_xtal"].translate(
|
||||
str.maketrans("", "", "()")
|
||||
) # Remove brackets from xtal name to conform with parameters
|
||||
index = bl.mo1.xtal.index(xtal)
|
||||
|
||||
xtal_pos = bl.mo1.xtalOffsetX[index]
|
||||
xtal_length_1 = bl.mo1.xtalLength1[index]
|
||||
xtal_length_2 = bl.mo1.xtalLength2[index]
|
||||
|
||||
width_beam = 2 * bl.mo1.center[1] * np.tan(cfg["h_acc"])
|
||||
|
||||
height_beam = 2 * bl.cm.center[1] * np.tan(cfg["v_acc"])
|
||||
w = height_beam / np.sin(cfg["mo1_bragg"])
|
||||
|
||||
if cfg["mo1_mode"] == "Monochromatic":
|
||||
out["mo1_1"]["x"] = [
|
||||
xtal_pos - width_beam / 2,
|
||||
xtal_pos + width_beam / 2,
|
||||
xtal_pos + width_beam / 2,
|
||||
xtal_pos - width_beam / 2,
|
||||
]
|
||||
out["mo1_1"]["y"] = [
|
||||
xtal_length_1 / 2 - c - w / 2,
|
||||
xtal_length_1 / 2 - c - w / 2,
|
||||
xtal_length_1 / 2 - c + w / 2,
|
||||
xtal_length_1 / 2 - c + w / 2,
|
||||
]
|
||||
out["mo1_2"]["x"] = [
|
||||
xtal_pos - width_beam / 2,
|
||||
xtal_pos + width_beam / 2,
|
||||
xtal_pos + width_beam / 2,
|
||||
xtal_pos - width_beam / 2,
|
||||
]
|
||||
out["mo1_2"]["y"] = [
|
||||
-xtal_length_2 / 2 + e - w / 2,
|
||||
-xtal_length_2 / 2 + e - w / 2,
|
||||
-xtal_length_2 / 2 + e + w / 2,
|
||||
-xtal_length_2 / 2 + e + w / 2,
|
||||
]
|
||||
else: # Pinkbeam
|
||||
out["mo1_1"]["x"] = []
|
||||
out["mo1_1"]["y"] = []
|
||||
out["mo1_2"]["x"] = []
|
||||
out["mo1_2"]["y"] = []
|
||||
|
||||
if cfg["fm_stripe"] is None:
|
||||
return out
|
||||
# Focusing mirror
|
||||
if cfg["fm_stripe"] in ("Rh (toroid)", "Pt (toroid)"):
|
||||
surface = bl.fm.surfaceToroid
|
||||
stripe = re.sub(r"\s*\(.*?\)", "", cfg["fm_stripe"]).strip()
|
||||
index = surface.index(stripe)
|
||||
r = bl.fm.r[index]
|
||||
else:
|
||||
surface = bl.fm.surfaceFlat
|
||||
stripe = re.sub(r"\s*\(.*?\)", "", cfg["fm_stripe"]).strip()
|
||||
index = surface.index(stripe)
|
||||
r = bl.fm.r[index]
|
||||
off = -cfg["fm_trx"]
|
||||
|
||||
width_beam = 2 * bl.fm.center[1] * np.tan(cfg["h_acc"])
|
||||
|
||||
if cfg["fm_stripe"] in ("Rh (toroid)", "Pt (toroid)"):
|
||||
|
||||
l = height_beam / np.sin(cfg["fm_rotx"])
|
||||
alpha = np.arccos(1 - width_beam**2 / (2 * r**2))
|
||||
h = r - (r * np.cos(alpha / 2))
|
||||
z = h / np.tan(cfg["fm_rotx"])
|
||||
|
||||
x = [off - width_beam / 2, off - width_beam / 2]
|
||||
y = [l / 2 - z / 2, -l / 2 - z / 2]
|
||||
|
||||
res = 20
|
||||
x_elipse = np.linspace(0, np.pi, res)
|
||||
y_elipse = np.linspace(0, np.pi, res)
|
||||
x_elipse = [-width_beam / 2 * np.cos(i) + off for i in x_elipse]
|
||||
y_elipse = [width_beam * np.sin(i) * z / width_beam - l / 2 - z / 2 for i in y_elipse]
|
||||
|
||||
x.extend(x_elipse)
|
||||
y.extend(y_elipse)
|
||||
|
||||
x.extend([off + width_beam / 2, off + width_beam / 2])
|
||||
y.extend([-l / 2 - z / 2, l / 2 - z / 2])
|
||||
|
||||
res = 50
|
||||
x_elipse = np.linspace(np.pi, 0, res)
|
||||
y_elipse = np.linspace(np.pi, 0, res)
|
||||
x_elipse = [-width_beam / 2 * np.cos(i) + off for i in x_elipse]
|
||||
y_elipse = [width_beam * np.sin(i) * z / width_beam + l / 2 - z / 2 for i in y_elipse]
|
||||
|
||||
x.extend(x_elipse)
|
||||
y.extend(y_elipse)
|
||||
|
||||
out["fm"]["x"] = x
|
||||
out["fm"]["y"] = y
|
||||
|
||||
else: # flat surface, no toroid
|
||||
l = height_beam / np.sin(cfg["fm_rotx"])
|
||||
|
||||
w1 = 2 * (bl.fm.center[1] - l / 2) * np.tan(cfg["h_acc"])
|
||||
w2 = 2 * (bl.fm.center[1] + l / 2) * np.tan(cfg["h_acc"])
|
||||
|
||||
out["fm"]["x"] = [off - w1 / 2, off + w1 / 2, off + w2 / 2, off - w2 / 2]
|
||||
out["fm"]["y"] = [-l / 2, -l / 2, l / 2, l / 2]
|
||||
|
||||
return out
|
||||
@@ -1,519 +0,0 @@
|
||||
"""
|
||||
Various calculations for the digital twin
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Literal, cast
|
||||
|
||||
import numpy as np
|
||||
from bec_lib import bec_logger
|
||||
from scipy.interpolate import UnivariateSpline
|
||||
from xrt.backends.raycing.physconsts import AVOGADRO, CHeVcm
|
||||
|
||||
from .. import parameters as bl
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
H = 6.62606957e-34
|
||||
E = 1.602176634e-19
|
||||
C = 299792458
|
||||
RE = 2.8179e-15
|
||||
|
||||
|
||||
def sldi_gap_to_acc(sldi_gapx: float, sldi_gapy: float) -> tuple[float, float]:
|
||||
"""
|
||||
Calculate the slits acceptance based on the gap values
|
||||
|
||||
Args:
|
||||
sldi_gapx(float): GAPX value of the slits in mm
|
||||
sldi_gapy(float): GAPY value of the slits in mm
|
||||
|
||||
Returns:
|
||||
tuple[float, float]: Horizontal and vertical acceptance in rad
|
||||
"""
|
||||
d1 = bl.feSlits.center1[1]
|
||||
d2 = bl.feSlits.center2[1]
|
||||
h_acc = np.tan(sldi_gapx / (d2 + d1))
|
||||
v_acc = np.tan(sldi_gapy / (d2 + d1))
|
||||
return h_acc, v_acc
|
||||
|
||||
|
||||
def cm_trx_to_stripe(cm_trx: float) -> str | None:
|
||||
"""
|
||||
Based on the trx value of the collimating mirror, return
|
||||
the correct stripe
|
||||
|
||||
Args:
|
||||
cm_trx(float): Collimating mirror trx value
|
||||
|
||||
Returns
|
||||
str | None: Stripe of the mirror, None if not found
|
||||
"""
|
||||
cm_stripe = None
|
||||
for name, low, high in zip(bl.cm.surface, bl.cm.limOptX[0], bl.cm.limOptX[1]):
|
||||
if low <= cm_trx <= high:
|
||||
cm_stripe = name
|
||||
return cm_stripe
|
||||
|
||||
|
||||
def cm_stripe_to_trx(cm_stripe: str) -> float | None:
|
||||
"""
|
||||
Based on the stripe of the collimating mirror, return
|
||||
the trx value
|
||||
|
||||
Args:
|
||||
cm_stripe(str): Stripe of the collimating mirror
|
||||
|
||||
Returns:
|
||||
float | None: TRX value of the stripe. None if not found
|
||||
"""
|
||||
for name, low, high in zip(bl.cm.surface, bl.cm.limOptX[0], bl.cm.limOptX[1]):
|
||||
if cm_stripe == name:
|
||||
return -(low + high) / 2
|
||||
return None
|
||||
|
||||
|
||||
def fm_trx_to_stripe(fm_trx: float) -> str | None:
|
||||
"""
|
||||
Based on the trx value of the focusing mirror, return
|
||||
the correct stripe
|
||||
|
||||
Args:
|
||||
fm_trx(float): focusing mirror trx value
|
||||
|
||||
Returns
|
||||
str | None: Stripe of the mirror, None if not found
|
||||
"""
|
||||
fm_stripe = None
|
||||
if hasattr(bl.fm, "surfaceFlat"):
|
||||
for name, low, high in zip(bl.fm.surfaceFlat, bl.fm.limOptXFlat[1], bl.fm.limOptXFlat[0]):
|
||||
if low <= fm_trx <= high:
|
||||
fm_stripe = name + " (flat)"
|
||||
for name, low, high in zip(bl.fm.surfaceToroid, bl.fm.limOptXToroid[1], bl.fm.limOptXToroid[0]):
|
||||
if low <= fm_trx <= high:
|
||||
fm_stripe = name + " (toroid)"
|
||||
return fm_stripe
|
||||
|
||||
|
||||
def fm_stripe_to_trx(fm_stripe: str) -> float | None:
|
||||
"""
|
||||
Based on the stripe of the focusing mirror, return
|
||||
the trx value
|
||||
|
||||
Args:
|
||||
fm_stripe(str): Stripe of the focusing mirror
|
||||
|
||||
Returns:
|
||||
float | None: TRX value of the stripe. None if not found
|
||||
"""
|
||||
if hasattr(bl.fm, "surfaceFlat"):
|
||||
for name, low, high in zip(bl.fm.surfaceFlat, bl.fm.limOptXFlat[1], bl.fm.limOptXFlat[0]):
|
||||
if fm_stripe == name + " (flat)":
|
||||
return (low + high) / 2
|
||||
for name, low, high in zip(bl.fm.surfaceToroid, bl.fm.limOptXToroid[1], bl.fm.limOptXToroid[0]):
|
||||
if fm_stripe == name + " (toroid)":
|
||||
return -(low + high) / 2
|
||||
return None
|
||||
|
||||
|
||||
def mo1_energy_resolution(xtal: Literal["Si111", "Si311"], energy: float) -> float:
|
||||
"""
|
||||
Calculate the energy resolution of the monochromator
|
||||
|
||||
Args:
|
||||
xtal(str): Xtal name. "Si111" or "Si311"
|
||||
energy(float): Energy in eV
|
||||
|
||||
Returns:
|
||||
float: Energy resolution in eV
|
||||
"""
|
||||
index = bl.mo1.xtal.index(xtal)
|
||||
crystal = bl.mo1.material1[index]
|
||||
|
||||
dtheta = np.linspace(-30, 90, 601)
|
||||
theta = crystal.get_Bragg_angle(energy) + dtheta * 1e-6
|
||||
refl = np.abs(crystal.get_amplitude(energy, np.sin(theta))[0]) ** 2 # single crystal
|
||||
|
||||
refl2 = refl**2 # DCM with parallel crystals
|
||||
|
||||
# FWHM of the DCM curve
|
||||
spline = UnivariateSpline(dtheta, refl2 - refl2.max() / 2, s=0)
|
||||
roots = cast(np.ndarray, spline.roots())
|
||||
r1, r2 = float(roots[0]), float(roots[1])
|
||||
fwhm_rad = (r2 - r1) * 1e-6 # µrad → rad
|
||||
|
||||
# Energy resolution
|
||||
theta_b = crystal.get_Bragg_angle(energy)
|
||||
de_over_e = fwhm_rad / np.tan(theta_b)
|
||||
de = de_over_e * energy
|
||||
|
||||
# logger.info(f"DCM FWHM : {r2-r1:.2f} µrad")
|
||||
# logger.info(f"ΔE/E : {dE_over_E:.2e}")
|
||||
# logger.info(f"ΔE : {dE:.3f} eV at {E} eV")
|
||||
|
||||
return de
|
||||
|
||||
|
||||
def cm_reflectivity(cm_stripe: str, cm_pitch: float, energy: float) -> float:
|
||||
"""
|
||||
Calculate the reflectivity of the mirror stripe based
|
||||
on the pitch and energy.
|
||||
|
||||
Args:
|
||||
cm_stripe(str): Mirror stripe
|
||||
cm_pitch(float): Pitch of the mirror (beam incidence angle)
|
||||
energy(float): Energy of the beam in eV
|
||||
|
||||
Returns:
|
||||
float: Reflectivity [0-1]
|
||||
"""
|
||||
if cm_stripe is None:
|
||||
return np.nan
|
||||
index = bl.cm.surface.index(cm_stripe)
|
||||
rs, _ = bl.cm.material[index].get_amplitude(energy, np.sin(cm_pitch))[0:2]
|
||||
refl = abs(rs) ** 2
|
||||
return refl
|
||||
|
||||
|
||||
def fm_reflectivity(fm_stripe: str, fm_pitch: float, energy: float) -> float:
|
||||
"""
|
||||
Calculate the reflectivity of the mirror stripe based
|
||||
on the pitch and energy.
|
||||
|
||||
Args:
|
||||
cm_stripe(str): Mirror stripe
|
||||
cm_pitch(float): Pitch of the mirror (beam incidence angle)
|
||||
energy(float): Energy of the beam in eV
|
||||
|
||||
Returns:
|
||||
float: Reflectivity [0-1]
|
||||
"""
|
||||
if fm_stripe is None:
|
||||
return np.nan
|
||||
if fm_stripe in ("Rh (toroid)", "Pt (toroid)"):
|
||||
surface = bl.fm.surfaceToroid
|
||||
material = bl.fm.materialToroid
|
||||
stripe = re.sub(r"\s*\(.*?\)", "", fm_stripe).strip()
|
||||
index = surface.index(stripe)
|
||||
else:
|
||||
surface = bl.fm.surfaceFlat
|
||||
material = bl.fm.materialFlat
|
||||
stripe = re.sub(r"\s*\(.*?\)", "", fm_stripe).strip()
|
||||
index = surface.index(stripe)
|
||||
rs, _ = material[index].get_amplitude(energy, np.sin(fm_pitch))[0:2]
|
||||
refl = abs(rs) ** 2
|
||||
return refl
|
||||
|
||||
|
||||
def mo1_bragg_angle(
|
||||
mo_mode: Literal["Monochromatic", "Pinkbeam"], d_spacing: float, energy: float, cm_pitch: float
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
Calculate the bragg angle of the monochromator.
|
||||
Corrects for the collimating mirror pitch.
|
||||
|
||||
Args:
|
||||
mo_mode(str): Monochromator mode. "Monochromatic" or "Pinkbeam"
|
||||
d_spacing(float): D-spacing of the crystal in Angstrom
|
||||
energy(float): Energy of the beam in eV
|
||||
cm_pitch(float): Pitch of collimating mirror in rad
|
||||
|
||||
Returns:
|
||||
tuple[float, float]: Bragg angle and corrected bragg angle
|
||||
"""
|
||||
wl = C * H / (E * energy)
|
||||
val = wl / (2 * d_spacing * 1e-10)
|
||||
bragg_angle = 0
|
||||
if val > -1 and val < 1:
|
||||
bragg_angle = np.asin(val)
|
||||
if mo_mode == "Monochromatic":
|
||||
# Add 2x CM pitch to the bragg angle
|
||||
bragg_angle_cor = (2 * cm_pitch) + bragg_angle
|
||||
else:
|
||||
# Align xtal surfaces parallel to beam
|
||||
bragg_angle_cor = 2 * cm_pitch
|
||||
return bragg_angle, bragg_angle_cor
|
||||
|
||||
|
||||
def fm_ideal_pitch(
|
||||
fm_focus: Literal["Defocused", "Focused", "Manual"],
|
||||
fm_stripe: str,
|
||||
smpl: float,
|
||||
sldi_hacc: float | None = None,
|
||||
sldi_vacc: float | None = None,
|
||||
fm_focx: float | None = None,
|
||||
fm_focy: float | None = None,
|
||||
) -> tuple[float, float | None]:
|
||||
"""
|
||||
Calculates the ideal pitch for the focusing mirror depending on the
|
||||
focusing strategy.
|
||||
If "Defocused" is chosed, sldi_hacc, sldi_vacc, fm_focx and fm_focy
|
||||
must be provided.
|
||||
|
||||
Args:
|
||||
fm_focus(str): Focus strategy. "Defocused", "Focused" or "Manual
|
||||
fm_stripe(str): Mirror stripe
|
||||
smpl(float): Sample position in mm from source
|
||||
sldi_hacc(float): Horizontal acceptance of frontend slits. Defaults to None
|
||||
sldi_vacc(float): Vertical acceptance of frontend slits. Defaults to None
|
||||
fm_focx(float): Requested horizontal spot size in mm. Defaults to None
|
||||
fm_focy(float): Requested vertical spot size in mm. Defaults to None
|
||||
|
||||
Returns:
|
||||
tuple[float, float | None]: Pitch of mirror in rad, qy in mm
|
||||
"""
|
||||
|
||||
# logger.info("Calculate pitch and qy now...")
|
||||
# logger.info(f"sldi_hacc: {sldi_hacc}")
|
||||
# logger.info(f"sldi_vacc: {sldi_vacc}")
|
||||
# logger.info(f"fm_stripe: {fm_stripe}")
|
||||
# logger.info(f"smpl: {smpl}")
|
||||
p_cm = bl.cm.center[1] # posCM
|
||||
p = bl.fm.center[1] # posFM
|
||||
q = smpl - bl.fm.center[1] # dist posFM to posEX
|
||||
if fm_focus == "Defocused":
|
||||
assert sldi_hacc is not None, "sldi_hacc must be provided for Defocused mode"
|
||||
assert sldi_vacc is not None, "sldi_vacc must be provided for Defocused mode"
|
||||
assert fm_focx is not None, "fm_focx must be provided for Defocused mode"
|
||||
assert fm_focy is not None, "fm_focy must be provided for Defocused mode"
|
||||
a = 2 * np.tan(sldi_hacc) * bl.fm.center[1] # Beam width at focusing mirror
|
||||
# logger.info(f"a: {a}")
|
||||
# logger.info(f"sldi_hacc: {sldi_hacc}")
|
||||
# logger.info(f"bl.fm.center[1]: {bl.fm.center[1]}")
|
||||
# logger.info(f"p: {p}")
|
||||
# logger.info(f"q: {q}")
|
||||
b = (
|
||||
2 * np.tan(sldi_vacc) * bl.cm.center[1]
|
||||
) # Beam height at focusing mirror (collimated beam)
|
||||
x = fm_focx
|
||||
# logger.info(f"x: {x}")
|
||||
x = 0.098821 * x**2 + 0.512344 * x # polynom to correct for spot size
|
||||
# logger.info(f"x (corrected): {x}")
|
||||
y = fm_focy
|
||||
y = 3.183562 * y**2 + 1.258364 * y # polynom to correct for spot size
|
||||
qx = q + x * p / a
|
||||
qy = q + y * p_cm / b
|
||||
f = (p * qx) / (p + qx) # focal length
|
||||
# logger.info(f"qx: {qx}")
|
||||
# logger.info(f"f: {f}")
|
||||
else: # Calculate for focused beam on sample in "manual" and "focused" mode
|
||||
qy = None
|
||||
f = (p * q) / (p + q) # focal length
|
||||
pitch = 0
|
||||
if "Rh" in fm_stripe:
|
||||
pitch = np.arcsin(bl.fm.r[0] / (2 * f)) # ideal pitch for FM
|
||||
if "Pt" in fm_stripe:
|
||||
pitch = np.arcsin(bl.fm.r[1] / (2 * f)) # ideal pitch for FM
|
||||
# logger.info(f"fm_pitch: {pitch}")
|
||||
# logger.info(f"qy: {qy}")
|
||||
return pitch, qy
|
||||
|
||||
|
||||
def calc_beamsize(
|
||||
sldi_hacc: float,
|
||||
sldi_vacc: float,
|
||||
fm_stripe: str,
|
||||
fm_pitch: float,
|
||||
fm_radius: float,
|
||||
smpl: float,
|
||||
) -> tuple[float, float | None]:
|
||||
"""
|
||||
Calculate the resulting beamsize according to the input parameters
|
||||
|
||||
Args:
|
||||
sldi_hacc(float): Horizontal acceptance of frontend slits
|
||||
sldi_vacc(float): Vertical acceptance of frontend slits
|
||||
fm_stripe(str): Mirror stripe
|
||||
fm_pitch(float): Focusing mirror pitch in rad
|
||||
fm_radius(float): Focusing mirror bender radius in m
|
||||
smpl(float): Sample position in mm from source
|
||||
|
||||
Returns:
|
||||
tuple[float, float | None]: horizontal spot size, vertical spot size, both in mm
|
||||
"""
|
||||
|
||||
# logger.info("Calculate beamsize now...")
|
||||
# logger.info(f"sldi_hacc: {sldi_hacc}")
|
||||
# logger.info(f"sldi_vacc: {sldi_vacc}")
|
||||
# logger.info(f"fm_stripe: {fm_stripe}")
|
||||
# logger.info(f"fm_pitch: {fm_pitch}")
|
||||
# logger.info(f"fm_radius: {fm_radius}")
|
||||
# logger.info(f"smpl: {smpl}")
|
||||
p_cm = bl.cm.center[1] # posCM
|
||||
p = bl.fm.center[1] # posFM
|
||||
q = smpl - bl.fm.center[1] # dist posFM to posEX
|
||||
qy = fm_radius * np.sin(fm_pitch) / 2
|
||||
a = 2 * np.tan(sldi_hacc) * bl.fm.center[1] # Beam width at focusing mirror
|
||||
b = 2 * np.tan(sldi_vacc) * bl.cm.center[1] # Beam height at focusing mirror (collimated beam)
|
||||
f = 0
|
||||
if "Rh" in fm_stripe:
|
||||
f = bl.fm.r[0] / (2 * np.sin(fm_pitch))
|
||||
if "Pt" in fm_stripe:
|
||||
f = bl.fm.r[1] / (2 * np.sin(fm_pitch))
|
||||
qx = p * f / (p - f)
|
||||
x = a * (qx - q) / p
|
||||
y = b * (qy - q) / p_cm
|
||||
# Change this | to a plus if calculation is not correct
|
||||
fm_focx = -4 * (64043 - 125000 * np.sqrt(0.26249637 + 0.395284 * x)) / 98821
|
||||
# Change this | to a plus if calculation is not correct
|
||||
fm_focy = -1 * (314591 - 250000 * np.sqrt(1.58347995 + 12.734248 * y)) / 1591781
|
||||
# logger.info(f"f: {f}")
|
||||
# logger.info(f"qx: {qx}")
|
||||
# logger.info(f"qy: {qy}")
|
||||
# logger.info(f"fm_focx: {fm_focx}")
|
||||
# logger.info(f"fm_focy: {fm_focy}")
|
||||
return fm_focx, fm_focy
|
||||
|
||||
|
||||
def cm_critical_angle(cm_stripe: Literal["Si", "Pt", "Rh"], energy) -> float:
|
||||
"""
|
||||
Calculate the critical angle of the mirror stripe
|
||||
|
||||
Args:
|
||||
cm_stripe(str): Mirror stripe. "Si", "Pt" or "Rh"
|
||||
energy(float): Energy in eV
|
||||
|
||||
Returns:
|
||||
float: Critical angle in rad
|
||||
"""
|
||||
if cm_stripe == "Si":
|
||||
stripe = bl.stripeSi
|
||||
elif cm_stripe == "Pt":
|
||||
stripe = bl.stripePt
|
||||
else:
|
||||
stripe = bl.stripeRh
|
||||
w = CHeVcm / 100 / energy # convert energy [eV] to wavelength [m]
|
||||
f1 = stripe.elements[0].Z + np.real(stripe.elements[0].get_f1f2(energy))
|
||||
number_density = stripe.rho * 1e3 * AVOGADRO / (stripe.elements[0].mass / 1e3)
|
||||
critical_angle = np.sqrt(number_density * RE * w**2 * f1 / np.pi)
|
||||
return critical_angle
|
||||
|
||||
|
||||
def mirror_surface_geometries(
|
||||
mirror: Literal["cm", "fm_toroid", "fm_flat"],
|
||||
) -> dict[str, tuple[float, float, float, float]]:
|
||||
"""
|
||||
Return the mirror stripe geometries
|
||||
|
||||
Args:
|
||||
mirror(str): Mirror. "cm", "fm_toroid" or "fm_flat"
|
||||
|
||||
Returns:
|
||||
dict[str, tuple[float, float, float, float]]: Dictionary mapping surface
|
||||
names to tuples of (x, y, width, height).
|
||||
"""
|
||||
if mirror == "cm":
|
||||
surface = bl.cm.surface
|
||||
lim_opt_x = bl.cm.limOptX
|
||||
lim_opt_y = bl.cm.limOptY
|
||||
elif mirror == "fm_toroid":
|
||||
surface = bl.fm.surfaceToroid
|
||||
lim_opt_x = bl.fm.limOptXToroid
|
||||
lim_opt_y = bl.fm.limOptYToroid
|
||||
elif mirror == "fm_flat":
|
||||
surface = bl.fm.surfaceFlat
|
||||
lim_opt_x = bl.fm.limOptXFlat
|
||||
lim_opt_y = bl.fm.limOptYFlat
|
||||
else:
|
||||
raise ValueError(f"Requested mirror {mirror} not available!")
|
||||
geom = {}
|
||||
for sf, lx, hx, ly, hy in zip(surface, lim_opt_x[0], lim_opt_x[1], lim_opt_y[0], lim_opt_y[1]):
|
||||
geom[sf] = (lx, ly, hx - lx, hy - ly)
|
||||
return geom
|
||||
|
||||
|
||||
def mo_surface_geometries(
|
||||
mo: Literal["mo1"], plane: Literal[0, 1]
|
||||
) -> dict[str, tuple[float, float, float, float]]:
|
||||
"""
|
||||
Return the monochromator xtal geometries
|
||||
|
||||
Args:
|
||||
mo(str): Monochromator. Only "mo1" implemented
|
||||
plane(int): Surface of xtal. 0 and 1 (First and second)
|
||||
|
||||
Returns:
|
||||
dict[str, tuple[float, float, float, float]]: Dictionary mapping surface
|
||||
names to tuples of (x, y, width, height).
|
||||
"""
|
||||
if mo == "mo1":
|
||||
xtal = bl.mo1.xtal
|
||||
xtal_width = bl.mo1.xtalWidth
|
||||
xtal_offset_x = bl.mo1.xtalOffsetX
|
||||
if plane == 0:
|
||||
xtal_length = bl.mo1.xtalLength1
|
||||
else:
|
||||
xtal_length = bl.mo1.xtalLength2
|
||||
else:
|
||||
return {}
|
||||
geom = {}
|
||||
for sf, w, offx, length in zip(xtal, xtal_width, xtal_offset_x, xtal_length):
|
||||
geom[sf] = (offx - w / 2, -length / 2, w, length)
|
||||
return geom
|
||||
|
||||
|
||||
def wall_geometries() -> list[list[float]]:
|
||||
"""
|
||||
Return the wall geometries
|
||||
|
||||
Returns:
|
||||
list[list[float]]: List of [x, y, width, height] geometry values for each wall.
|
||||
"""
|
||||
geom = []
|
||||
if not hasattr(bl, "walls"):
|
||||
return geom
|
||||
for i, _ in enumerate(bl.walls.start):
|
||||
geom.append(
|
||||
[
|
||||
bl.walls.start[i],
|
||||
bl.walls.height[i][0],
|
||||
bl.walls.end[i] - bl.walls.start[i],
|
||||
bl.walls.height[i][1] - bl.walls.height[i][0],
|
||||
]
|
||||
)
|
||||
return geom
|
||||
|
||||
|
||||
def pipe_geometries() -> list[dict[str, np.ndarray]]:
|
||||
"""
|
||||
Return the wall geometries
|
||||
|
||||
Returns:
|
||||
list[dict[str, np.ndarray]]: List of dictionaries with keys "x" and "y",
|
||||
each containing a numpy array of two float values representing
|
||||
the start and end coordinates of the pipe top and bottom edges.
|
||||
"""
|
||||
pipes = []
|
||||
if not hasattr(bl, "vacuum_pipes"):
|
||||
return pipes
|
||||
for i, _ in enumerate(bl.vacuum_pipes.center):
|
||||
top = bl.vacuum_pipes.center[i] + bl.vacuum_pipes.diameter[i] / 2 + bl.sourceHeight
|
||||
bottom = bl.vacuum_pipes.center[i] - bl.vacuum_pipes.diameter[i] / 2 + bl.sourceHeight
|
||||
pipes.append(
|
||||
{
|
||||
"x": np.array([bl.vacuum_pipes.start[i], bl.vacuum_pipes.end[i]]),
|
||||
"y": np.array([top, top]),
|
||||
}
|
||||
)
|
||||
pipes.append(
|
||||
{
|
||||
"x": np.array([bl.vacuum_pipes.start[i], bl.vacuum_pipes.end[i]]),
|
||||
"y": np.array([bottom, bottom]),
|
||||
}
|
||||
)
|
||||
return pipes
|
||||
|
||||
|
||||
def table_to_smpl_pos(table: str) -> float:
|
||||
"""
|
||||
Return the sample position based on the table name.
|
||||
|
||||
Args:
|
||||
table (str): Table name, e.g. ES1 or ES2
|
||||
"""
|
||||
|
||||
if table == bl.es1.name:
|
||||
return bl.es1.center[1]
|
||||
if table == bl.es2.name:
|
||||
return bl.es2.center[1]
|
||||
raise ValueError(f"Table {table} not found in beamline parameter file")
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
GlitchesPreview: QDialog to preview monochromator glitches
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import xraydb
|
||||
from bec_lib import bec_logger
|
||||
from bec_widgets.utils.colors import Colors, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
# pylint: disable=E0611
|
||||
from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
from .azimuth_calculator import parse_xop_macro, predicted_energies
|
||||
from .widgets.qt_widgets import ComboBox, InputNumberField
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
EV_BELOW = 200
|
||||
EV_ABOVE = 1000
|
||||
|
||||
|
||||
def edge_step(energies, edge_energy, width_ev=1.5):
|
||||
"""Plain logistic step 0 -> 1 (no EXAFS/pre-edge), for a readable
|
||||
visual reference of where a real edge would fall."""
|
||||
x = np.clip(-(energies - edge_energy) / width_ev, -500, 500)
|
||||
return 1.0 / (1.0 + np.exp(x))
|
||||
|
||||
|
||||
class GlitchesPreview(QDialog):
|
||||
"""QDialog to preview monochromator glitches"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
current_xtal: str,
|
||||
current_azm: float,
|
||||
ranges: dict,
|
||||
element: str | None = "",
|
||||
edge: str | None = "",
|
||||
energy: float = 12000,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Glitches Preview")
|
||||
|
||||
if element != "":
|
||||
self.energy = xraydb.xray_edge(element, edge, energy_only=True)
|
||||
self.energies = np.linspace(self.energy - EV_BELOW, self.energy + EV_ABOVE, 1000)
|
||||
self.mu = edge_step(self.energies, self.energy)
|
||||
else:
|
||||
self.energy = energy
|
||||
self.energies = np.linspace(self.energy - EV_BELOW, self.energy + EV_ABOVE, 1000)
|
||||
self.mu = np.ones(len(self.energies))
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
text = QLabel("Preview of monochromator glitches.")
|
||||
layout.addWidget(text)
|
||||
|
||||
self.xtal_selector = ComboBox("mo1_xtal", "Crystal", ["Si(111)", "Si(311)"])
|
||||
self.xtal_selector.set_current_text(current_xtal)
|
||||
self.xtal_selector.activated_connect(self.change_xtal)
|
||||
self.old_xtal = current_xtal
|
||||
layout.addWidget(self.xtal_selector)
|
||||
|
||||
self.azm_angle = current_azm
|
||||
self.ranges = ranges
|
||||
self.azm_input = None
|
||||
if ranges[current_xtal]["ll"] is not None and ranges[current_xtal]["hl"] is not None:
|
||||
self.azm_input = InputNumberField(
|
||||
"azm_angle",
|
||||
"Azm. Angle",
|
||||
unit="deg",
|
||||
init=self.azm_angle,
|
||||
decimals=3,
|
||||
single_step=0.01,
|
||||
ll=ranges[current_xtal]["ll"],
|
||||
hl=ranges[current_xtal]["hl"],
|
||||
)
|
||||
self.azm_input.value_changed_connect(self.update_plot)
|
||||
layout.addWidget(self.azm_input)
|
||||
accept_text = "Select crystal and azimuthal angle"
|
||||
else:
|
||||
accept_text = "Select crystal"
|
||||
|
||||
self.plot = pg.PlotWidget()
|
||||
self.plot.setLabel("bottom", "Energy", units="eV")
|
||||
self.plot.setLabel("left", "Beam Intensity")
|
||||
self.plot.addLegend()
|
||||
self.plot.setYRange(-0.15, 1.2)
|
||||
color = Colors.golden_angle_color(colormap="plasma", num=1, format="HEX")[0]
|
||||
self.curve_edge = self.plot.plot(pen=pg.mkPen(color=color, width=2))
|
||||
layout.addWidget(self.plot)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
self._cancel_button = QPushButton("Close")
|
||||
self._cancel_button.clicked.connect(self.reject)
|
||||
self._cancel_button.setStyleSheet(
|
||||
f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}"
|
||||
)
|
||||
self._select_button = QPushButton(accept_text)
|
||||
self._select_button.clicked.connect(self.accept)
|
||||
self._select_button.setStyleSheet(
|
||||
f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}"
|
||||
)
|
||||
|
||||
self._cancel_button.setAutoDefault(False)
|
||||
self._cancel_button.setDefault(False)
|
||||
self._select_button.setAutoDefault(False)
|
||||
self._select_button.setDefault(False)
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(self._cancel_button)
|
||||
button_layout.addWidget(self._select_button)
|
||||
button_layout.addStretch()
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self.adjustSize()
|
||||
self.setFixedSize(self.sizeHint())
|
||||
|
||||
self.reflections_si111 = parse_xop_macro(
|
||||
Path(__file__).with_name("xop_si111_Si-LN2_8-8-8_4500-60000eV_-180-180deg.pro")
|
||||
)
|
||||
self.reflections_si311 = parse_xop_macro(
|
||||
Path(__file__).with_name("xop_si311_Si-LN2_8-8-8_4500-60000eV_0-360deg.pro")
|
||||
)
|
||||
self.update_plot()
|
||||
|
||||
@SafeSlot()
|
||||
def change_xtal(self, *_, **__):
|
||||
diff = self.azm_angle - self.ranges[self.old_xtal]["ll"]
|
||||
xtal_info = self.ranges[self.xtal_selector.currentText()]
|
||||
self.azm_input.set_range(xtal_info["ll"], xtal_info["hl"])
|
||||
self.azm_input.set_number(xtal_info["ll"] + diff)
|
||||
self.old_xtal = self.xtal_selector.currentText()
|
||||
self.update_plot()
|
||||
|
||||
@SafeSlot()
|
||||
def update_plot(self, *_, **__):
|
||||
if isinstance(self.azm_input, InputNumberField):
|
||||
self.azm_angle = self.azm_input.value()
|
||||
if self.xtal_selector.currentText() == "Si(111)":
|
||||
reflections = self.reflections_si111
|
||||
else:
|
||||
reflections = self.reflections_si311
|
||||
|
||||
e_pred, fh, _ = predicted_energies(
|
||||
reflections, self.azm_angle, (self.energies[0], self.energies[-1])
|
||||
)
|
||||
|
||||
if len(fh) == 0:
|
||||
self.curve_edge.setData(self.energies, self.mu)
|
||||
logger.info(f"phi = {self.azm_angle:.1f} deg | 0 glitches in window")
|
||||
return
|
||||
|
||||
logw = np.log10(fh)
|
||||
wnorm = (
|
||||
(logw - logw.min()) / (logw.max() - logw.min())
|
||||
if logw.max() > logw.min()
|
||||
else np.ones_like(logw)
|
||||
)
|
||||
|
||||
mu = self.mu
|
||||
for e, wn in zip(e_pred, wnorm):
|
||||
depth = 0.02 + 0.08 * wn
|
||||
mu = mu - depth * np.exp(-0.5 * ((self.energies - e) / 1.5) ** 2)
|
||||
|
||||
self.curve_edge.setData(self.energies, mu)
|
||||
@@ -7,7 +7,7 @@ from typing import Union
|
||||
# pylint: disable=E0611
|
||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from ..types import BeamlineId
|
||||
from .....bec_ipython_client.plugins.beamline import BeamlineId
|
||||
from ..widgets.qt_widgets import (
|
||||
Button,
|
||||
ComboBox,
|
||||
@@ -113,8 +113,32 @@ class InputPanel(QWidget):
|
||||
self.mo1_xtal = ComboBox("mo1_xtal", "Crystal", ["Si(111)", "Si(311)"])
|
||||
self.mo1_bragg_angle = NumberIndicator("Bragg Angle", "deg", decimals=1)
|
||||
self.mo1_eres = NumberIndicator("Energy Resolution", "eV", decimals=2)
|
||||
|
||||
if beamline == BeamlineId.X01DA:
|
||||
self.mo1_azm_angle = InputNumberField(
|
||||
"azm_angle",
|
||||
"Azm. Angle",
|
||||
unit="deg",
|
||||
init=0,
|
||||
decimals=3,
|
||||
single_step=0.01,
|
||||
ll=-10,
|
||||
hl=10,
|
||||
)
|
||||
else:
|
||||
self.mo1_azm_angle = NumberIndicator("Azm. Angle", "deg", decimals=3)
|
||||
self.mo1_glitch_preview = Button("Glitches Preview", "Open", enabled=True)
|
||||
|
||||
self.mo1_ass_group = Group(
|
||||
"Monochromator", [self.mo1_mode, self.mo1_xtal, self.mo1_bragg_angle, self.mo1_eres]
|
||||
"Monochromator",
|
||||
[
|
||||
self.mo1_mode,
|
||||
self.mo1_xtal,
|
||||
self.mo1_bragg_angle,
|
||||
self.mo1_eres,
|
||||
self.mo1_azm_angle,
|
||||
self.mo1_glitch_preview,
|
||||
],
|
||||
)
|
||||
|
||||
# Focusing Mirror
|
||||
@@ -173,7 +197,45 @@ class InputPanel(QWidget):
|
||||
|
||||
# Sample
|
||||
self.cm_fm_harm_suppr = NumberIndicator("Total Suppression Factor at x eV", "", decimals=0)
|
||||
self.smpl = self._create_smpl(beamline)
|
||||
|
||||
self.es1man_trz = InputNumberField(
|
||||
"es1man_trz",
|
||||
"Sample Manipulator TRZ",
|
||||
unit="mm",
|
||||
init=0,
|
||||
decimals=1,
|
||||
single_step=1,
|
||||
ll=-60,
|
||||
hl=60,
|
||||
)
|
||||
|
||||
if beamline == BeamlineId.X01DA:
|
||||
self.ot_es1_trz = InputNumberField(
|
||||
"ot_es1_trz",
|
||||
"ES1 Position",
|
||||
unit="mm",
|
||||
init=23511,
|
||||
decimals=0,
|
||||
single_step=100,
|
||||
ll=23000,
|
||||
hl=30000,
|
||||
)
|
||||
self.smpl_to_xrd = InputNumberField(
|
||||
"smpl_to_xrd",
|
||||
"Sample to XRD Det",
|
||||
unit="mm",
|
||||
init=320,
|
||||
decimals=0,
|
||||
single_step=10,
|
||||
ll=320,
|
||||
hl=6000,
|
||||
)
|
||||
self.smpl_group = Group(
|
||||
"Sample Position", [self.ot_es1_trz, self.es1man_trz, self.smpl_to_xrd]
|
||||
)
|
||||
else:
|
||||
self.table = ComboBox("table", "Sample Position", ["ES1", "ES2"])
|
||||
self.smpl_group = Group("Sample Position", [self.es1man_trz])
|
||||
|
||||
# Assemble complete assistant group
|
||||
self.input_group = Group(
|
||||
@@ -186,25 +248,9 @@ class InputPanel(QWidget):
|
||||
self.mo1_ass_group,
|
||||
self.fm_ass_group,
|
||||
self.cm_fm_harm_suppr,
|
||||
self.smpl,
|
||||
self.smpl_group,
|
||||
],
|
||||
)
|
||||
|
||||
self._layout.addWidget(self.input_group)
|
||||
self._layout.addStretch()
|
||||
|
||||
def _create_smpl(self, beamline: BeamlineId) -> Union[InputNumberField, ComboBox]:
|
||||
match beamline:
|
||||
case BeamlineId.X01DA:
|
||||
return InputNumberField(
|
||||
"smpl",
|
||||
"Sample Position",
|
||||
unit="mm",
|
||||
init=23511,
|
||||
decimals=0,
|
||||
single_step=100,
|
||||
ll=23000,
|
||||
hl=30000,
|
||||
)
|
||||
case BeamlineId.X10DA:
|
||||
return ComboBox("smpl", "Sample Position", ["ES1", "ES2"])
|
||||
@@ -7,24 +7,27 @@ from typing import Literal
|
||||
# pylint: disable=E0611
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from ..types import BeamlineId
|
||||
from .....bec_ipython_client.plugins.beamline import BeamlineId
|
||||
from ..widgets.move_widget import AbsorberWidget, MoveWidget
|
||||
from ..widgets.qt_widgets import Group
|
||||
from ..widgets.qt_widgets import Button, Group, QLabel
|
||||
|
||||
|
||||
class MoverPanel(QWidget):
|
||||
""" "Panel to move an axis to a certain position"""
|
||||
|
||||
def __init__(self, beamline: BeamlineId, dev, parent=None):
|
||||
def __init__(self, core, beamline: BeamlineId, dev, parent=None):
|
||||
super().__init__(parent)
|
||||
self._layout = QVBoxLayout(self)
|
||||
self._layout.setContentsMargins(4, 4, 4, 4)
|
||||
self._layout.setSpacing(4)
|
||||
|
||||
self.core = core
|
||||
|
||||
self.mover_widgets = []
|
||||
|
||||
# FE Slits
|
||||
self.sldi_gapx = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="sldi_gapx",
|
||||
@@ -36,6 +39,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.sldi_gapx)
|
||||
|
||||
self.sldi_gapy = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="sldi_gapy",
|
||||
@@ -55,6 +59,7 @@ class MoverPanel(QWidget):
|
||||
|
||||
# Collimating mirror
|
||||
self.cm_trx = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="cm_trx",
|
||||
@@ -66,6 +71,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.cm_trx)
|
||||
|
||||
self.cm_try = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="cm_try",
|
||||
@@ -77,6 +83,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.cm_try)
|
||||
|
||||
self.cm_bnd = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="cm_bnd",
|
||||
@@ -88,6 +95,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.cm_bnd)
|
||||
|
||||
self.cm_rotx = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="cm_rotx",
|
||||
@@ -104,6 +112,7 @@ class MoverPanel(QWidget):
|
||||
|
||||
# Monochromator
|
||||
self.mo1_bragg_angle = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="mo1_bragg_angle",
|
||||
@@ -115,6 +124,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.mo1_bragg_angle)
|
||||
|
||||
self.mo1_trx = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="mo1_trx",
|
||||
@@ -126,6 +136,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.mo1_trx)
|
||||
|
||||
self.mo1_try = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="mo1_try",
|
||||
@@ -136,12 +147,30 @@ class MoverPanel(QWidget):
|
||||
)
|
||||
self.mover_widgets.append(self.mo1_try)
|
||||
|
||||
self.mo1_mov_group = Group(
|
||||
"Monochromator", [self.mo1_bragg_angle, self.mo1_trx, self.mo1_try]
|
||||
)
|
||||
if beamline == BeamlineId.X01DA:
|
||||
self.mo1_roty = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="mo1_roty",
|
||||
label="ROTY",
|
||||
unit="deg",
|
||||
decimals=3,
|
||||
deadband=0.01,
|
||||
)
|
||||
self.mover_widgets.append(self.mo1_roty)
|
||||
|
||||
self.mo1_mov_group = Group(
|
||||
"Monochromator", [self.mo1_bragg_angle, self.mo1_trx, self.mo1_try, self.mo1_roty]
|
||||
)
|
||||
else:
|
||||
self.mo1_mov_group = Group(
|
||||
"Monochromator", [self.mo1_bragg_angle, self.mo1_trx, self.mo1_try]
|
||||
)
|
||||
|
||||
# OP Slits 1
|
||||
self.sl1_centery = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="sl1_centery",
|
||||
@@ -153,6 +182,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.sl1_centery)
|
||||
|
||||
self.sl1_gapy = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="sl1_gapy",
|
||||
@@ -167,6 +197,7 @@ class MoverPanel(QWidget):
|
||||
|
||||
# OP Beam Monitor 1
|
||||
self.bm1_try = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="bm1_try",
|
||||
@@ -181,6 +212,7 @@ class MoverPanel(QWidget):
|
||||
|
||||
# Focusing Mirror
|
||||
self.fm_trx = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="fm_trx",
|
||||
@@ -192,6 +224,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.fm_trx)
|
||||
|
||||
self.fm_try = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="fm_try",
|
||||
@@ -203,6 +236,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.fm_try)
|
||||
|
||||
self.fm_bnd = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="fm_bnd",
|
||||
@@ -214,6 +248,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.fm_bnd)
|
||||
|
||||
self.fm_rotx = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="fm_rotx",
|
||||
@@ -225,6 +260,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.fm_rotx)
|
||||
|
||||
self.fm_roty = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="fm_roty",
|
||||
@@ -236,6 +272,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.fm_roty)
|
||||
|
||||
self.fm_rotz = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="fm_rotz",
|
||||
@@ -254,6 +291,7 @@ class MoverPanel(QWidget):
|
||||
if beamline == "x01da":
|
||||
# OP Slits 2
|
||||
self.sl2_centery = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="sl2_centery",
|
||||
@@ -265,6 +303,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.sl2_centery)
|
||||
|
||||
self.sl2_gapy = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="sl2_gapy",
|
||||
@@ -279,6 +318,7 @@ class MoverPanel(QWidget):
|
||||
|
||||
# OP Beam Monitor 2
|
||||
self.bm2_try = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="bm2_try",
|
||||
@@ -294,6 +334,7 @@ class MoverPanel(QWidget):
|
||||
if beamline == "x01da":
|
||||
# Optical Table
|
||||
self.ot_try = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="ot_try",
|
||||
@@ -305,6 +346,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.ot_try)
|
||||
|
||||
self.ot_rotx = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="ot_rotx",
|
||||
@@ -319,6 +361,7 @@ class MoverPanel(QWidget):
|
||||
|
||||
# Experimental Station 0
|
||||
self.es0wi_try = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="es0wi_try",
|
||||
@@ -334,6 +377,7 @@ class MoverPanel(QWidget):
|
||||
# Experimental Station 1
|
||||
if beamline == "x01da":
|
||||
self.ot_es1_trz = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="ot_es1_trz",
|
||||
@@ -343,9 +387,21 @@ class MoverPanel(QWidget):
|
||||
deadband=5,
|
||||
)
|
||||
self.mover_widgets.append(self.ot_es1_trz)
|
||||
self.es1man_trz = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="es1man_trz",
|
||||
label="MANIP TRZ",
|
||||
unit="mm",
|
||||
decimals=2,
|
||||
deadband=0.1,
|
||||
)
|
||||
self.mover_widgets.append(self.es1man_trz)
|
||||
|
||||
if beamline == "x10da":
|
||||
self.es1_try = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="es1_try",
|
||||
@@ -357,6 +413,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.es1_try)
|
||||
|
||||
self.es1ic0_try = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="es1ic0_try",
|
||||
@@ -368,6 +425,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.es1ic0_try)
|
||||
|
||||
self.es1ic1_try = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="es1ic1_try",
|
||||
@@ -379,6 +437,7 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.es1ic1_try)
|
||||
|
||||
self.es1ic2_try = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="es1ic2_try",
|
||||
@@ -390,15 +449,29 @@ class MoverPanel(QWidget):
|
||||
self.mover_widgets.append(self.es1ic2_try)
|
||||
|
||||
if beamline == "x01da":
|
||||
self.es1_mov_group = Group("Experimental Station 1", [self.ot_es1_trz])
|
||||
self.es1_mov_group = Group("Experimental Station 1", [self.ot_es1_trz, self.es1man_trz])
|
||||
else:
|
||||
self.es1_mov_group = Group(
|
||||
"Experimental Station 1", [self.es1_try, self.es1ic1_try, self.es1ic2_try]
|
||||
)
|
||||
|
||||
# Experimental Station 2
|
||||
if beamline == "x01da":
|
||||
self.ot_es2_trz = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="ot_es2_trz",
|
||||
label="ES2 TRZ",
|
||||
unit="mm",
|
||||
decimals=0,
|
||||
deadband=5,
|
||||
)
|
||||
self.mover_widgets.append(self.ot_es2_trz)
|
||||
|
||||
if beamline == "x10da":
|
||||
self.es2_try = MoveWidget(
|
||||
core=self.core,
|
||||
beamline=beamline,
|
||||
dev=dev,
|
||||
motor="es2_try",
|
||||
@@ -409,8 +482,18 @@ class MoverPanel(QWidget):
|
||||
)
|
||||
self.mover_widgets.append(self.es2_try)
|
||||
|
||||
if beamline == "x01da":
|
||||
self.es2_mov_group = Group("Experimental Station 2", [self.ot_es2_trz])
|
||||
else:
|
||||
self.es2_mov_group = Group("Experimental Station 2", [self.es2_try])
|
||||
|
||||
# Multi-Mover
|
||||
self.multi_move = Button(label_button="Move Selection", enabled=True, color="success")
|
||||
self.multi_move_info = QLabel(
|
||||
"Not all motors can move together at the same time, digital twin will take care of that."
|
||||
)
|
||||
self.multi_move_group = Group("Multi Move", [self.multi_move, self.multi_move_info])
|
||||
|
||||
# Assemble complete mover group
|
||||
if beamline == "x01da":
|
||||
self.mover_group = Group(
|
||||
@@ -428,6 +511,8 @@ class MoverPanel(QWidget):
|
||||
self.ot_mov_group,
|
||||
self.es0_mov_group,
|
||||
self.es1_mov_group,
|
||||
self.es2_mov_group,
|
||||
self.multi_move_group,
|
||||
],
|
||||
)
|
||||
else:
|
||||
@@ -445,6 +530,7 @@ class MoverPanel(QWidget):
|
||||
self.es0_mov_group,
|
||||
self.es1_mov_group,
|
||||
self.es2_mov_group,
|
||||
self.multi_move_group,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -15,13 +15,11 @@ from qtpy.QtGui import QBrush, QColor
|
||||
# pylint: disable=E0611
|
||||
from qtpy.QtWidgets import QApplication, QGraphicsRectItem, QHBoxLayout, QVBoxLayout, QWidget
|
||||
|
||||
from ..calculations.calc_varia import (
|
||||
mirror_surface_geometries,
|
||||
mo_surface_geometries,
|
||||
pipe_geometries,
|
||||
wall_geometries,
|
||||
from .....bec_ipython_client.plugins.digital_twin_core.types import (
|
||||
BeamlineId,
|
||||
DataDict,
|
||||
SurfaceDict,
|
||||
)
|
||||
from ..types import BeamlineId, DataDict, SurfaceDict
|
||||
from ..widgets.qt_widgets import Group
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -30,9 +28,10 @@ logger = bec_logger.logger
|
||||
class SurfacePlots(QWidget):
|
||||
"""Plot widget with two curves and legend."""
|
||||
|
||||
def __init__(self, beamline: BeamlineId, parent=None):
|
||||
def __init__(self, beamline: BeamlineId, core, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.beamline = beamline
|
||||
self.core = core
|
||||
self._layout = QHBoxLayout(self)
|
||||
self._layout.setContentsMargins(4, 4, 4, 4)
|
||||
self._layout.setSpacing(6)
|
||||
@@ -169,15 +168,15 @@ class SurfacePlots(QWidget):
|
||||
|
||||
for name, plot in self.plots.items():
|
||||
if name == "cm":
|
||||
plot_surface(plot["widget"], mirror_surface_geometries("cm"))
|
||||
plot_surface(plot["widget"], self.core.mirror_surface_geometries("cm"))
|
||||
elif name == "mo1_1":
|
||||
plot_surface(plot["widget"], mo_surface_geometries("mo1", 0))
|
||||
plot_surface(plot["widget"], self.core.mo_surface_geometries("mo1", 0))
|
||||
elif name == "mo1_2":
|
||||
plot_surface(plot["widget"], mo_surface_geometries("mo1", 1))
|
||||
plot_surface(plot["widget"], self.core.mo_surface_geometries("mo1", 1))
|
||||
elif name == "fm":
|
||||
if self.beamline == "x01da":
|
||||
plot_surface(plot["widget"], mirror_surface_geometries("fm_flat"))
|
||||
plot_surface(plot["widget"], mirror_surface_geometries("fm_toroid"))
|
||||
plot_surface(plot["widget"], self.core.mirror_surface_geometries("fm_flat"))
|
||||
plot_surface(plot["widget"], self.core.mirror_surface_geometries("fm_toroid"))
|
||||
else:
|
||||
raise ValueError(f"Plot {name} not found!")
|
||||
for name, plot in self.plots.items():
|
||||
@@ -203,8 +202,9 @@ class SurfacePlots(QWidget):
|
||||
class SideviewPlot(QWidget):
|
||||
"""Plot widget with two curves and legend."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
def __init__(self, core, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.core = core
|
||||
self._layout = QVBoxLayout(self)
|
||||
self._layout.setContentsMargins(4, 4, 4, 4)
|
||||
self._layout.setSpacing(0)
|
||||
@@ -303,7 +303,7 @@ class SideviewPlot(QWidget):
|
||||
|
||||
def plot_vacuum_pipes(self):
|
||||
"""Plot vacuum pipes"""
|
||||
pipes = pipe_geometries()
|
||||
pipes = self.core.pipe_geometries()
|
||||
for pipe in pipes:
|
||||
self.pipes.append(
|
||||
self.plot_widget.plot(
|
||||
@@ -313,7 +313,7 @@ class SideviewPlot(QWidget):
|
||||
|
||||
def plot_walls(self):
|
||||
"""Plot walls"""
|
||||
walls = wall_geometries()
|
||||
walls = self.core.wall_geometries()
|
||||
for wall in walls:
|
||||
rect = QGraphicsRectItem(wall[0], wall[1], wall[2], wall[3])
|
||||
rect.setBrush(QBrush(QColor(*self.color_impenetrable)))
|
||||
|
||||
@@ -27,5 +27,20 @@ class SettingsPanel(QWidget):
|
||||
"Axes Offsets", [self.load_offsets, self.offsets_status, self.show_offsets]
|
||||
)
|
||||
|
||||
self._layout.addWidget(self.offset_group)
|
||||
self.load_from_scan = Button(
|
||||
label="Load from Scan", label_button="Load from Scan", enabled=True
|
||||
)
|
||||
self.load_from_config = Button(
|
||||
label="Load from Config", label_button="Load from Config", enabled=True
|
||||
)
|
||||
self.save_config = Button(label="Save Config", label_button="Save Config", enabled=True)
|
||||
|
||||
self.import_export_group = Group(
|
||||
"Import/Export", [self.load_from_scan, self.load_from_config, self.save_config]
|
||||
)
|
||||
|
||||
# Assemble complete settings group
|
||||
self.settings_group = Group("Settings", [self.offset_group, self.import_export_group])
|
||||
|
||||
self._layout.addWidget(self.settings_group)
|
||||
self._layout.addStretch()
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Move widget to display an axis and also move it through BEC"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Literal, Optional
|
||||
|
||||
from bec_lib import bec_logger
|
||||
@@ -11,24 +10,24 @@ from bec_widgets.utils.colors import get_accent_colors
|
||||
# pylint: disable=E0611
|
||||
from qtpy.QtCore import Property # type: ignore[attr-defined]
|
||||
from qtpy.QtCore import Signal # type: ignore[attr-defined]
|
||||
from qtpy.QtCore import QObject, QPropertyAnimation, Qt, QThread
|
||||
from qtpy.QtCore import QObject, QPropertyAnimation, Qt
|
||||
from qtpy.QtGui import QTransform
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QPushButton, QWidget
|
||||
from qtpy.QtWidgets import QApplication, QCheckBox, QHBoxLayout, QLabel, QPushButton, QWidget
|
||||
|
||||
# pylint: disable=E0402
|
||||
from .....devices.absorber import STATUS as ABS_STATUS
|
||||
from ..types import BeamlineId
|
||||
from .....bec_ipython_client.plugins.digital_twin_core.motion_worker import (
|
||||
MotionOutcome,
|
||||
MotionWorker,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class Status:
|
||||
"""Status class for the axis"""
|
||||
|
||||
IN_POSITION = "in_position" # green mdi.check-circle
|
||||
NOT_IN_POSITION = "not_in_position" # orange mdi.close-circle
|
||||
MOVING = "moving" # blue mdi.loading (spinning)
|
||||
ERROR = "error" # red mdi.alert-circle
|
||||
IN_POSITION = "in_position" # green
|
||||
NOT_IN_POSITION = "not_in_position" # orange
|
||||
QUEUED = "queued" # grey, waiting for another motor group
|
||||
MOVING = "moving" # blue
|
||||
ERROR = "error" # red
|
||||
|
||||
|
||||
class StatusIcon(QWidget):
|
||||
@@ -44,6 +43,7 @@ class StatusIcon(QWidget):
|
||||
Status.NOT_IN_POSITION: ("cancel", "#e6d922"),
|
||||
Status.ERROR: ("warning", "#e74c3c"),
|
||||
Status.MOVING: ("cycle", "#2980b9"),
|
||||
Status.QUEUED: ("hourglass_empty", "#7f8c8d"),
|
||||
}
|
||||
|
||||
def __init__(self, parent=None):
|
||||
@@ -123,223 +123,13 @@ class StatusIcon(QWidget):
|
||||
self._label.setPixmap(icon)
|
||||
|
||||
|
||||
class MotionWorker(QObject):
|
||||
"""
|
||||
Executes motion on the specified motor and includes some safety during
|
||||
motion for certain motors.
|
||||
"""
|
||||
|
||||
class _WorkerSignals(QObject):
|
||||
position_changed = Signal(float)
|
||||
error = Signal()
|
||||
waiting = Signal() # must wait for another subgroup
|
||||
started = Signal() # slot acquired, motion begins
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self, beamline: BeamlineId, dev, motor, target_pos: float):
|
||||
super().__init__()
|
||||
self.beamline = beamline
|
||||
self.dev = dev
|
||||
self.motor = motor
|
||||
self._target = target_pos
|
||||
self._stop_flag = threading.Event()
|
||||
|
||||
def stop(self):
|
||||
"""Sets the stop flag"""
|
||||
self._stop_flag.set()
|
||||
|
||||
def run(self):
|
||||
"""Prepares the movement based on the axis (motor)"""
|
||||
match self.motor:
|
||||
case "sldi_gapx" | "sldi_gapy" | "sldi_centerx" | "sldi_centery":
|
||||
self.motion()
|
||||
case "cm_trx":
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[{"device": self.dev["cm_roty"], "abs_tol": 0.05}],
|
||||
)
|
||||
case "cm_roty":
|
||||
self.motion(
|
||||
abs_closed=True, surveyed_axes=[{"device": self.dev["cm_trx"], "abs_tol": 0.05}]
|
||||
)
|
||||
case "cm_try":
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[
|
||||
{"device": self.dev["cm_rotx"], "abs_tol": 0.05},
|
||||
{"device": self.dev["cm_rotz"], "abs_tol": 0.05},
|
||||
],
|
||||
)
|
||||
case "cm_rotx":
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[
|
||||
{"device": self.dev["cm_try"], "abs_tol": 0.05},
|
||||
{"device": self.dev["cm_rotz"], "abs_tol": 0.05},
|
||||
],
|
||||
)
|
||||
case "cm_rotz":
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[
|
||||
{"device": self.dev["cm_try"], "abs_tol": 0.05},
|
||||
{"device": self.dev["cm_rotx"], "abs_tol": 0.05},
|
||||
],
|
||||
)
|
||||
case "cm_bnd":
|
||||
if self.beamline == "x01da":
|
||||
p1 = (
|
||||
1 / (self.dev.cm_bnd_radius.read()["cm_bnd_radius"]["value"] * 1e3) + 0.0284
|
||||
) / 2e-6
|
||||
p2 = (1 / (self._target * 1e3) + 0.0284) / 2e-6
|
||||
else:
|
||||
p1 = 541900 / self.dev.cm_bnd_radius.read()["cm_bnd_radius"]["value"] - 32570
|
||||
p2 = 541900 / self._target - 32570
|
||||
self._target = p2 - p1
|
||||
self.motion(relative=True, rb={"device": self.dev["cm_bnd_radius"]})
|
||||
case "mo1_try" | "mo1_trx" | "mo1_roty":
|
||||
self.motion(abs_closed=True)
|
||||
case "mo1_bragg_angle":
|
||||
if self.beamline == "x01da":
|
||||
self.motion()
|
||||
else: # x10da needs to move goniometer
|
||||
self.motion(alias="mo1_rotx")
|
||||
case "sl1_centery" | "sl1_gapy" | "bm1_try":
|
||||
self.motion()
|
||||
case "fm_trx":
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[{"device": self.dev["fm_roty"], "abs_tol": 0.05}],
|
||||
)
|
||||
case "fm_roty":
|
||||
self.motion(
|
||||
abs_closed=True, surveyed_axes=[{"device": self.dev["fm_trx"], "abs_tol": 0.05}]
|
||||
)
|
||||
case "fm_try":
|
||||
if self.beamline == "x01da":
|
||||
abs_tol = 0.05
|
||||
else: # superxas mirror less stable thus needs higher tolerance
|
||||
abs_tol = 0.2
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[
|
||||
{"device": self.dev["fm_rotx"], "abs_tol": abs_tol},
|
||||
{"device": self.dev["fm_rotz"], "abs_tol": abs_tol},
|
||||
],
|
||||
)
|
||||
case "fm_rotx":
|
||||
if self.beamline == "x01da":
|
||||
abs_tol = 0.05
|
||||
else: # superxas mirror less stable thus needs higher tolerance
|
||||
abs_tol = 0.2
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[
|
||||
{"device": self.dev["fm_try"], "abs_tol": abs_tol},
|
||||
{"device": self.dev["fm_rotz"], "abs_tol": abs_tol},
|
||||
],
|
||||
)
|
||||
case "fm_rotz":
|
||||
if self.beamline == "x01da":
|
||||
abs_tol = 0.05
|
||||
else: # superxas mirror less stable thus needs higher tolerance
|
||||
abs_tol = 0.2
|
||||
self.motion(
|
||||
abs_closed=True,
|
||||
surveyed_axes=[
|
||||
{"device": self.dev["fm_try"], "abs_tol": abs_tol},
|
||||
{"device": self.dev["fm_rotx"], "abs_tol": abs_tol},
|
||||
],
|
||||
)
|
||||
case "fm_bnd":
|
||||
if self.beamline == "x01da":
|
||||
p1 = (
|
||||
1 / (self.dev.fm_bnd_radius.read()["fm_bnd_radius"]["value"] * 1e3)
|
||||
+ 4.28e-5
|
||||
) / 1.84e-9
|
||||
p2 = (1 / (self._target * 1e3) + 4.28e-5) / 1.84e-9
|
||||
else:
|
||||
p1 = (
|
||||
593088.7 / self.dev.fm_bnd_radius.read()["fm_bnd_radius"]["value"]
|
||||
+ 26124.41
|
||||
)
|
||||
p2 = 593088.7 / self._target + 26124.41
|
||||
self._target = p2 - p1
|
||||
self.motion(relative=True, rb={"device": self.dev["fm_bnd_radius"]})
|
||||
case "sl2_centery" | "sl2_gapy" | "bm2_try":
|
||||
self.motion()
|
||||
case "ot_try" | "ot_rotx" | "ot_es1_trz":
|
||||
self.motion()
|
||||
case "es0wi_try":
|
||||
self.motion()
|
||||
case "es1_try" | "es2_try":
|
||||
self.motion()
|
||||
case "es1ic0_try" | "es1ic1_try" | "es1ic2_try":
|
||||
self.motion()
|
||||
case _:
|
||||
logger.warning(f"Motor {self.motor} not integrated in digital twin!")
|
||||
|
||||
def motion(
|
||||
self,
|
||||
abs_closed: bool = False,
|
||||
relative: bool = False,
|
||||
rb=None,
|
||||
surveyed_axes=None,
|
||||
alias=None,
|
||||
):
|
||||
"""
|
||||
Moves an axis while surverying a set of axes (if set).
|
||||
Example surveyed_axes:
|
||||
[{'device': bec_device_object, 'abs_tol': 0.1},]
|
||||
|
||||
Args:
|
||||
surveyed_axes (list): List of dictionaries of devices
|
||||
"""
|
||||
try:
|
||||
if alias:
|
||||
self.motor = alias
|
||||
if abs_closed:
|
||||
if self.dev.abs.status.get() == ABS_STATUS.OPEN:
|
||||
status = self.dev.abs.close()
|
||||
# TODO Set timeout to 0.001 and check if it actually raises
|
||||
# (it should not start motion).
|
||||
# Check of behavior of digital twin afterwards.
|
||||
status.wait(timeout=5)
|
||||
if surveyed_axes is not None:
|
||||
for surv_ax in surveyed_axes:
|
||||
surv_ax["name"] = surv_ax["device"].dotted_name
|
||||
surv_ax["old_value"] = surv_ax["device"].read(cached=True)[surv_ax["name"]][
|
||||
"value"
|
||||
]
|
||||
if rb is not None:
|
||||
rb["name"] = rb["device"].dotted_name
|
||||
status = self.dev[self.motor].move(self._target, relative=relative)
|
||||
last_check = time.time()
|
||||
update_interval = 0.1
|
||||
while status.status == "RUNNING":
|
||||
now = time.time()
|
||||
if time.time() - last_check < update_interval:
|
||||
time.sleep(0.01)
|
||||
last_check = now
|
||||
if self._stop_flag.is_set():
|
||||
self.dev[self.motor].stop()
|
||||
self._stop_flag.clear()
|
||||
if rb is not None:
|
||||
self.position_changed.emit(rb["device"].read(cached=True)[rb["name"]]["value"])
|
||||
else:
|
||||
self.position_changed.emit(
|
||||
self.dev[self.motor].read(cached=True)[self.motor]["value"]
|
||||
)
|
||||
if surveyed_axes is not None:
|
||||
for surv_ax in surveyed_axes:
|
||||
fb = surv_ax["device"].read(cached=True)[surv_ax["name"]]["value"]
|
||||
if abs(fb - surv_ax["old_value"]) > surv_ax["abs_tol"]:
|
||||
self.dev[self.motor].stop()
|
||||
self.error.emit()
|
||||
self.finished.emit()
|
||||
break
|
||||
self.finished.emit()
|
||||
except:
|
||||
self.error.emit()
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
class MoveWidget(QWidget):
|
||||
"""
|
||||
@@ -350,10 +140,13 @@ class MoveWidget(QWidget):
|
||||
- Start / Stop button
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, beamline: BeamlineId, dev, motor, label: str = "", unit=None, decimals=3, deadband=0.0
|
||||
):
|
||||
_STATUS_TOOLTIPS = {Status.QUEUED: "Waiting for other motors to finish"}
|
||||
|
||||
def __init__(self, core, beamline, dev, motor, label="", unit=None, decimals=3, deadband=0.0):
|
||||
super().__init__()
|
||||
self.core = core
|
||||
self._cancel: threading.Event | None = None
|
||||
|
||||
self.fb = 0.0
|
||||
self.target = 0
|
||||
self.beamline = beamline
|
||||
@@ -361,8 +154,9 @@ class MoveWidget(QWidget):
|
||||
self.motor = motor
|
||||
self.deadband = deadband
|
||||
self.status = Status.IN_POSITION
|
||||
self._thread: QThread | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._worker: MotionWorker | None = None
|
||||
self._signals: _WorkerSignals | None = None
|
||||
|
||||
self.text_color = (0, 0, 0)
|
||||
|
||||
@@ -373,6 +167,11 @@ class MoveWidget(QWidget):
|
||||
layout.setContentsMargins(4, 0, 4, 0)
|
||||
layout.setSpacing(4)
|
||||
|
||||
# Checkbox
|
||||
self.checkbox = QCheckBox()
|
||||
self.checkbox.setChecked(True)
|
||||
layout.addWidget(self.checkbox)
|
||||
|
||||
# Name
|
||||
self.label = QLabel(label)
|
||||
self.label.setFixedWidth(76)
|
||||
@@ -437,6 +236,11 @@ class MoveWidget(QWidget):
|
||||
+ f"{{background-color: {get_accent_colors().emergency.name()}; color: white;}}"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_busy(self) -> bool:
|
||||
"""True from start until the finished signal was handled (moving OR queued)."""
|
||||
return self._thread is not None
|
||||
|
||||
def set_target(self, target):
|
||||
"""Change the target value in the ui"""
|
||||
self.target = target
|
||||
@@ -473,13 +277,13 @@ class MoveWidget(QWidget):
|
||||
)
|
||||
|
||||
def _set_status(self, status: str):
|
||||
"""Set the current status icon in the ui"""
|
||||
self.status = status
|
||||
self.status_icon.set_status(status)
|
||||
self.status_icon.setToolTip(self._STATUS_TOOLTIPS.get(status, ""))
|
||||
|
||||
def _on_target_or_fb_changed(self):
|
||||
"""Re-evaluate in-position status whenever the target value changes."""
|
||||
if self.status in (Status.ERROR, Status.MOVING):
|
||||
if self.status in (Status.ERROR, Status.MOVING, Status.QUEUED):
|
||||
return
|
||||
if abs(self.fb - self.target) <= self.deadband:
|
||||
self._set_status(Status.IN_POSITION)
|
||||
@@ -487,45 +291,86 @@ class MoveWidget(QWidget):
|
||||
self._set_status(Status.NOT_IN_POSITION)
|
||||
|
||||
def _on_button_clicked(self):
|
||||
"""Starts or stops motion depending on current situation"""
|
||||
if self._thread and self._thread.isRunning():
|
||||
if self.is_busy:
|
||||
self._stop_motion()
|
||||
else:
|
||||
self._start_motion()
|
||||
self.start_motion()
|
||||
|
||||
def _start_motion(self):
|
||||
"""Start a motion"""
|
||||
target = self.target
|
||||
if abs(target - self.fb) <= self.deadband:
|
||||
def start_motion(self, abort: threading.Event | None = None):
|
||||
"""Start a motion. `abort` is shared by a batch (Move Selection) so that one failure
|
||||
prevents the not-yet-started motors of that batch from starting."""
|
||||
if self.is_busy:
|
||||
return
|
||||
if abs(self.target - self.fb) <= self.deadband:
|
||||
self._set_status(Status.IN_POSITION)
|
||||
return
|
||||
|
||||
self._set_status(Status.MOVING)
|
||||
self._apply_button_style("stop")
|
||||
self._cancel = threading.Event()
|
||||
|
||||
self._worker = MotionWorker(self.beamline, self.dev, self.motor, target)
|
||||
self._thread = QThread()
|
||||
self._worker.moveToThread(self._thread)
|
||||
|
||||
self._thread.started.connect(self._worker.run)
|
||||
self._worker.position_changed.connect(self._on_position_changed)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.error.connect(self._thread.quit)
|
||||
self._worker.finished.connect(self._on_motion_finished)
|
||||
self._worker.finished.connect(self._thread.quit)
|
||||
self._thread.finished.connect(self._cleanup_thread)
|
||||
signals = _WorkerSignals()
|
||||
signals.waiting.connect(self._on_waiting)
|
||||
signals.started.connect(self._on_started)
|
||||
signals.position_changed.connect(self._on_position_changed)
|
||||
signals.error.connect(self._on_error)
|
||||
signals.finished.connect(self._on_motion_finished)
|
||||
signals.finished.connect(self._cleanup_thread)
|
||||
self._signals = signals
|
||||
|
||||
# No on_finished here: _run emits it exactly once (MotionWorker.run never calls
|
||||
# it for motors that are "not integrated in digital twin").
|
||||
worker = MotionWorker(
|
||||
self.beamline,
|
||||
self.dev,
|
||||
self.motor,
|
||||
self.target,
|
||||
on_position_changed=signals.position_changed.emit,
|
||||
on_error=signals.error.emit,
|
||||
)
|
||||
self._worker = worker
|
||||
self._thread = threading.Thread(
|
||||
target=self._run, args=(worker, signals, self._cancel, abort), daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def _run(self, worker, signals, cancel, abort):
|
||||
try:
|
||||
outcome = self.core.run_exclusive(...)
|
||||
if outcome == MotionOutcome.FAILED and worker.last_exception is not None:
|
||||
signals.error.emit() # harmless if motion() already emitted it
|
||||
finally:
|
||||
signals.finished.emit()
|
||||
try:
|
||||
self.core.run_exclusive(
|
||||
worker,
|
||||
abort=abort,
|
||||
cancel=cancel,
|
||||
on_waiting=signals.waiting.emit,
|
||||
on_started=signals.started.emit,
|
||||
)
|
||||
except Exception:
|
||||
signals.error.emit()
|
||||
raise
|
||||
finally:
|
||||
signals.finished.emit()
|
||||
|
||||
def _on_waiting(self):
|
||||
self._set_status(Status.QUEUED)
|
||||
|
||||
def _on_started(self):
|
||||
self._set_status(Status.MOVING)
|
||||
|
||||
def _on_error(self):
|
||||
"""Called when an error occurs"""
|
||||
self._set_status(Status.ERROR)
|
||||
self._apply_button_style("start")
|
||||
|
||||
def _stop_motion(self):
|
||||
"""Attempts to stop the motion"""
|
||||
"""Stop a running motor, or remove a queued one from the queue."""
|
||||
if self._cancel:
|
||||
self._cancel.set() # releases a waiting motor within ~0.1 s
|
||||
if self._worker:
|
||||
self._worker.stop()
|
||||
self._worker.stop() # stops a running motor
|
||||
|
||||
def _on_position_changed(self, pos: float):
|
||||
"""Change the feedback value in the ui"""
|
||||
@@ -547,20 +392,16 @@ class MoveWidget(QWidget):
|
||||
|
||||
def _cleanup_thread(self):
|
||||
"""Cleaning up of the mover thread"""
|
||||
if self._thread:
|
||||
self._thread.deleteLater()
|
||||
self._thread = None
|
||||
if self._worker:
|
||||
self._worker.deleteLater()
|
||||
self._worker = None
|
||||
self._thread = None
|
||||
self._worker = None
|
||||
self._signals = None
|
||||
|
||||
def shutdown(self):
|
||||
"""Cleaning up of the mover when shutting down the application"""
|
||||
if self._worker:
|
||||
self._worker.stop()
|
||||
if self._thread:
|
||||
self._thread.quit()
|
||||
self._thread.wait(2000) # max 2 s grace period
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(2.0) # max 2 s grace period
|
||||
|
||||
|
||||
class AbsorberWidget(QWidget):
|
||||
|
||||
@@ -106,10 +106,20 @@ class InputNumberField(QWidget):
|
||||
if prefix is not None:
|
||||
self.val.setPrefix(prefix + " ")
|
||||
layout.addWidget(self.val)
|
||||
self.ll = ll
|
||||
self.hl = hl
|
||||
|
||||
def set_number(self, number):
|
||||
self.val.setValue(number)
|
||||
|
||||
def set_range(self, ll, hl):
|
||||
self.val.setRange(ll, hl)
|
||||
self.ll = ll
|
||||
self.hl = hl
|
||||
|
||||
def get_range(self):
|
||||
return (self.ll, self.hl)
|
||||
|
||||
def has_focus(self) -> bool:
|
||||
return self.val.hasFocus()
|
||||
|
||||
@@ -164,9 +174,14 @@ class ComboBox(QWidget):
|
||||
def setDisabled(self, disable):
|
||||
self.value.setDisabled(disable)
|
||||
|
||||
def get_all_texts(self) -> list[str]:
|
||||
return [self.value.itemText(i) for i in range(self.value.count())]
|
||||
|
||||
|
||||
class Button(QWidget):
|
||||
def __init__(self, label=None, label_button: str = "", enabled=False):
|
||||
def __init__(
|
||||
self, label=None, label_button: str = "", enabled: bool = False, color: str = "default"
|
||||
):
|
||||
super().__init__()
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(*ROW_MARGINS)
|
||||
@@ -176,28 +191,35 @@ class Button(QWidget):
|
||||
self.label.setFixedWidth(LABEL_WIDTH)
|
||||
layout.addWidget(self.label)
|
||||
self.button = QPushButton(label_button)
|
||||
self.enable_button(enabled)
|
||||
self.setEnabled(enabled)
|
||||
layout.addWidget(self.button)
|
||||
self.color = color
|
||||
|
||||
def clicked_connect(self, func):
|
||||
"""Connect a function to the button press."""
|
||||
self.button.clicked.connect(func)
|
||||
|
||||
def enable_button(self, enable: bool = False):
|
||||
if enable:
|
||||
self.button.setStyleSheet(
|
||||
f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}"
|
||||
)
|
||||
self.button.setEnabled(True)
|
||||
else: # disabled
|
||||
self.button.setStyleSheet(
|
||||
"QPushButton {{background-color: rgb(120, 120, 120); color: white;}}"
|
||||
)
|
||||
self.button.setDisabled(True)
|
||||
def setEnabled(self, enable: bool = True):
|
||||
self.button.setEnabled(enable)
|
||||
self.apply_theme()
|
||||
|
||||
def setText(self, text):
|
||||
self.button.setText(text)
|
||||
|
||||
def setColor(self, color: str):
|
||||
self.color = color
|
||||
self.apply_theme()
|
||||
|
||||
def apply_theme(self):
|
||||
if self.button.isEnabled():
|
||||
colors = get_accent_colors()
|
||||
color = getattr(colors, self.color).name()
|
||||
self.button.setStyleSheet(f"QPushButton {{ background-color: {color}; color: white; }}")
|
||||
else:
|
||||
self.button.setStyleSheet(
|
||||
"QPushButton {{background-color: rgb(120, 120, 120); color: white;}}"
|
||||
)
|
||||
|
||||
|
||||
class TextIndicator(QWidget):
|
||||
def __init__(self, label):
|
||||
|
||||
+7812
File diff suppressed because it is too large.
Load diff
+7902
File diff suppressed because it is too large.
Load diff
@@ -21,10 +21,6 @@ logger = bec_logger.logger
|
||||
class RestartServer(BECWidget, QWidget):
|
||||
"""
|
||||
Main widget of server restart widget.
|
||||
|
||||
This widget has no visible UI of its own - it only drives the confirmation/
|
||||
progress dialog. The surrounding dock panel is hidden as soon as we can find
|
||||
it, so the user only ever sees the dialog, never an empty "Restart Server" tab.
|
||||
"""
|
||||
|
||||
PLUGIN = True
|
||||
|
||||
@@ -37,6 +37,8 @@ from ..edge_selector import EdgeSelector
|
||||
logger = bec_logger.logger
|
||||
|
||||
ALLOWED_SCANS = [
|
||||
"acquire",
|
||||
"line_scan",
|
||||
"xas_simple_scan",
|
||||
"xas_simple_scan_with_xrd",
|
||||
"xas_advanced_scan",
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Redis endpoint(s) for the schedule widget plugin.
|
||||
|
||||
Design notes (why this looks the way it does)
|
||||
-----------------------------------------------
|
||||
BEC never touches Redis with raw keys/commands. Every channel is described
|
||||
by an `EndpointInfo` (endpoint string + message type + allowed operations,
|
||||
see `bec_lib.endpoints`), and `RedisConnector` enforces both: calling an
|
||||
operation that isn't in the endpoint's `MessageOp` raises
|
||||
`IncompatibleRedisOperation`, and passing a message that isn't an instance
|
||||
of the endpoint's declared `message_type` raises
|
||||
`IncompatibleMessageForEndpoint`. Plain string topics still work but are
|
||||
deprecated. So a "conforming" custom endpoint means building a real
|
||||
`EndpointInfo`, exactly like `bec_lib.endpoints.MessageEndpoints` does
|
||||
internally.
|
||||
|
||||
Namespace: we use `EndpointType.USER` ("user/...”), the same prefix BEC's
|
||||
own `MessageEndpoints.scan_queue_schedule()` uses for user-writable,
|
||||
persisted data (as opposed to `internal/`, `public/`, etc.).
|
||||
|
||||
Message type: we deliberately do NOT declare our own `BECMessage`
|
||||
subclass. BEC's msgpack codec (`bec_lib.codecs.BECMessageEncoder.decode`)
|
||||
resolves an incoming message's class by name via
|
||||
`getattr(bec_lib.messages, type_name)` - i.e. only classes physically
|
||||
defined inside `bec_lib.messages` are resolvable this way. A message class
|
||||
declared in plugin code would fail to deserialize unless you monkey-patch
|
||||
it into that module, which is exactly the kind of side-door this plugin
|
||||
is meant to avoid. Instead we reuse `bec_lib.messages.VariableMessage`,
|
||||
a first-class, exported message type built for carrying an arbitrary
|
||||
(msgpack-serializable) payload under `.value`.
|
||||
`bec_lib.script_executor.upload_script` persists a script's text in Redis
|
||||
the same way, for the same reason - it's the established BEC pattern for
|
||||
"a plugin needs to stash its own structured data in Redis".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib.endpoints import EndpointInfo, EndpointType, MessageOp
|
||||
from bec_lib.messages import VariableMessage
|
||||
|
||||
|
||||
def schedule(schedule_name: str) -> EndpointInfo:
|
||||
"""
|
||||
Endpoint for one named widget schedule (an ordered list of commands
|
||||
plus their execution status). The whole schedule is stored as a single
|
||||
`VariableMessage` document that gets overwritten and republished on
|
||||
every change (`MessageOp.SET_PUBLISH`), so any other subscriber -
|
||||
another instance of this widget, a monitoring script, ... - stays in
|
||||
sync live.
|
||||
|
||||
Args:
|
||||
schedule_name: a stable, user-chosen name for the schedule. Unlike
|
||||
a widget's `gui_id` (regenerated every time the widget is
|
||||
constructed), this name is what lets a widget that was closed
|
||||
and reopened find its own previously persisted schedule again.
|
||||
"""
|
||||
return EndpointInfo(
|
||||
endpoint=f"{EndpointType.USER.value}/schedule_widget/schedule/{schedule_name}",
|
||||
message_type=VariableMessage,
|
||||
message_op=MessageOp.SET_PUBLISH,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ScheduleItemStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
ABORTED = "aborted"
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
A small, self-contained utility for "if signal X drops below A, pause; once
|
||||
it's back above B, resume" (hysteresis) behavior - e.g. auto-pausing scans
|
||||
while the beam current is too low.
|
||||
|
||||
This module knows nothing about schedules, scans, or the rest of this
|
||||
plugin. It only monitors one BEC device's live readback value
|
||||
(`MessageEndpoints.device_readback`, the same endpoint the device server
|
||||
publishes for every monitored device) and emits Qt signals when the value
|
||||
crosses one of the two configured thresholds. `schedule_widget.py` is the
|
||||
only place that connects those signals to schedule-specific behavior
|
||||
(aborting/deferring a scan item) - see its module docstring for how.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtCore import QObject, Signal
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MIN_UPDATE_INTERVAL = 1
|
||||
|
||||
|
||||
class GuardSettings(BaseModel):
|
||||
"""Persisted configuration for one `SignalGuard`."""
|
||||
|
||||
enabled: bool = False
|
||||
device_name: str | None = None
|
||||
pause_below: float | None = None
|
||||
resume_above: float | None = None
|
||||
|
||||
|
||||
class SignalGuard(QObject):
|
||||
"""
|
||||
Monitors one device's readback value and reports hysteresis-based
|
||||
pause/resume crossings.
|
||||
|
||||
- `paused` fires the first time the value drops below `pause_below`
|
||||
(not again while it stays low).
|
||||
- `resumed` fires the first time it then climbs back above
|
||||
`resume_above` (not again while it stays high).
|
||||
A value sitting between the two thresholds never re-triggers either
|
||||
signal - that gap is the point of using two thresholds instead of one,
|
||||
so a value oscillating right at a single cutoff wouldn't cause rapid
|
||||
pause/resume flapping.
|
||||
|
||||
Callbacks from `RedisConnector.register()` run on a background
|
||||
(Redis-listener) thread, not the Qt GUI thread; `paused`/`resumed` are
|
||||
Qt signals, so connecting to them with the default (auto) connection
|
||||
type safely marshals delivery onto whatever thread the receiver lives
|
||||
on - no extra locking needed on the receiving end.
|
||||
"""
|
||||
|
||||
value_update = Signal(float)
|
||||
paused = Signal(float)
|
||||
resumed = Signal(float)
|
||||
|
||||
def __init__(self, connector, parent=None, dev=None):
|
||||
super().__init__(parent)
|
||||
self.dev = dev
|
||||
self._connector = connector
|
||||
self._lock = threading.Lock()
|
||||
self._clear_event = threading.Event()
|
||||
self._clear_event.set() # not blocking until configured/proven otherwise
|
||||
|
||||
self.enabled = False
|
||||
self.device_name: str | None = None
|
||||
self.pause_below: float | None = None
|
||||
self.resume_above: float | None = None
|
||||
self.current_value: float | None = None
|
||||
self.units: str = ""
|
||||
self.prec: int = 3
|
||||
|
||||
self._subscribed_endpoint = None
|
||||
|
||||
self.last_val_update = time.time()
|
||||
|
||||
def configure(self, settings: GuardSettings):
|
||||
"""(Re)configure and (re)subscribe. Safe to call repeatedly, e.g. after editing settings."""
|
||||
self._unsubscribe()
|
||||
|
||||
self.enabled = settings.enabled
|
||||
self.device_name = settings.device_name
|
||||
self.pause_below = settings.pause_below
|
||||
self.resume_above = settings.resume_above
|
||||
self.current_value = None
|
||||
self._clear_event.set()
|
||||
|
||||
if (
|
||||
self.enabled
|
||||
and self.device_name
|
||||
and self.pause_below is not None
|
||||
and self.resume_above is not None
|
||||
):
|
||||
self._subscribed_endpoint = MessageEndpoints.device_readback(self.device_name)
|
||||
self._connector.register(topics=self._subscribed_endpoint, cb=self._on_readback)
|
||||
description = self.dev[self.device_name].describe()[self.device_name]
|
||||
self.units = description["units"]
|
||||
self.prec = description["precision"]
|
||||
|
||||
def _unsubscribe(self):
|
||||
if self._subscribed_endpoint is not None:
|
||||
try:
|
||||
self._connector.unregister(topics=self._subscribed_endpoint, cb=self._on_readback)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
f"Failed to unsubscribe SignalGuard from {self._subscribed_endpoint}"
|
||||
)
|
||||
self._subscribed_endpoint = None
|
||||
|
||||
def _on_readback(self, msg):
|
||||
device_msg = getattr(msg, "value", None)
|
||||
if device_msg is None:
|
||||
return
|
||||
value = _extract_value(device_msg, self.device_name)
|
||||
if value is None:
|
||||
return
|
||||
|
||||
crossed_pause = crossed_resume = False
|
||||
with self._lock:
|
||||
self.current_value = value
|
||||
if self._clear_event.is_set():
|
||||
if value < self.pause_below:
|
||||
self._clear_event.clear()
|
||||
crossed_pause = True
|
||||
elif value > self.resume_above:
|
||||
self._clear_event.set()
|
||||
crossed_resume = True
|
||||
|
||||
if crossed_pause:
|
||||
logger.info(
|
||||
f"SignalGuard: {self.device_name} dropped to {value} (below {self.pause_below}) - pausing."
|
||||
)
|
||||
self.paused.emit(value)
|
||||
elif crossed_resume:
|
||||
logger.info(
|
||||
f"SignalGuard: {self.device_name} recovered to {value} (above {self.resume_above}) - resuming."
|
||||
)
|
||||
self.resumed.emit(value)
|
||||
|
||||
current_time = time.time()
|
||||
if current_time - self.last_val_update > MIN_UPDATE_INTERVAL:
|
||||
self.last_val_update = current_time
|
||||
self.value_update.emit(value)
|
||||
|
||||
def is_clear(self) -> bool:
|
||||
"""True if not currently blocking (disabled, or value above the resume threshold)."""
|
||||
return (not self.enabled) or self._clear_event.is_set()
|
||||
|
||||
def wait_until_clear(self, should_abort, poll_interval: float = 0.5) -> bool:
|
||||
"""
|
||||
Blocks the calling (non-GUI) thread until `is_clear()` becomes
|
||||
True, checking `should_abort()` between polls so an operator abort
|
||||
can still interrupt the wait. Returns True if it cleared, False if
|
||||
`should_abort()` returned True first.
|
||||
"""
|
||||
if self.is_clear():
|
||||
return True
|
||||
while not should_abort():
|
||||
if self._clear_event.wait(timeout=poll_interval):
|
||||
return True
|
||||
return False
|
||||
|
||||
def cleanup(self):
|
||||
self._unsubscribe()
|
||||
|
||||
|
||||
def _extract_value(device_msg, device_name: str | None):
|
||||
"""
|
||||
`DeviceMessage.signals` is keyed by signal name, e.g. {"beam_current":
|
||||
{"value": ..., "timestamp": ...}, ...}. For a simple scalar device the
|
||||
primary signal is usually named after the device itself; fall back to
|
||||
the first signal found if not (e.g. a differently-named primary signal
|
||||
on a compound device).
|
||||
"""
|
||||
signals = getattr(device_msg, "signals", None) or {}
|
||||
if device_name in signals:
|
||||
return signals[device_name].get("value")
|
||||
for signal in signals.values():
|
||||
if "value" in signal:
|
||||
return signal["value"]
|
||||
return None
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Small settings dialog for `guard.GuardSettings` - kept separate from the guard logic itself."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from .guard import GuardSettings
|
||||
|
||||
_DSPIN_RANGE = (-1e12, 1e12)
|
||||
|
||||
|
||||
class GuardSettingsDialog(QDialog):
|
||||
"""Configure (or disable) the auto-pause/resume guard for scan items."""
|
||||
|
||||
def __init__(self, settings: GuardSettings, device_names: list[str], parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Auto-pause on signal")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
info_label = QLabel(
|
||||
"If enabled, the currently running scan is aborted as soon as the chosen "
|
||||
"signal drops below the pause value, and automatically restarted once it "
|
||||
"rises back above the resume value. Only scan items are affected - device "
|
||||
"moves and custom/RPC commands are never interrupted by this."
|
||||
)
|
||||
info_label.setWordWrap(True)
|
||||
layout.addWidget(info_label)
|
||||
|
||||
form = QFormLayout()
|
||||
layout.addLayout(form)
|
||||
|
||||
self.enabled_check = QCheckBox("Enable auto-pause")
|
||||
self.enabled_check.setChecked(settings.enabled)
|
||||
form.addRow("", self.enabled_check)
|
||||
|
||||
self.device_combo = QComboBox()
|
||||
self.device_combo.addItems(device_names)
|
||||
if settings.device_name:
|
||||
idx = self.device_combo.findText(settings.device_name)
|
||||
if idx >= 0:
|
||||
self.device_combo.setCurrentIndex(idx)
|
||||
form.addRow("Signal (device)", self.device_combo)
|
||||
|
||||
self.pause_below_spin = QDoubleSpinBox()
|
||||
self.pause_below_spin.setDecimals(4)
|
||||
self.pause_below_spin.setRange(*_DSPIN_RANGE)
|
||||
if settings.pause_below is not None:
|
||||
self.pause_below_spin.setValue(settings.pause_below)
|
||||
form.addRow("Pause below", self.pause_below_spin)
|
||||
|
||||
self.resume_above_spin = QDoubleSpinBox()
|
||||
self.resume_above_spin.setDecimals(4)
|
||||
self.resume_above_spin.setRange(*_DSPIN_RANGE)
|
||||
if settings.resume_above is not None:
|
||||
self.resume_above_spin.setValue(settings.resume_above)
|
||||
form.addRow("Resume above", self.resume_above_spin)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self._on_accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def _on_accept(self):
|
||||
if self.enabled_check.isChecked():
|
||||
if not self.device_combo.currentText():
|
||||
QMessageBox.warning(self, "Missing input", "Select a device to monitor.")
|
||||
return
|
||||
if self.resume_above_spin.value() <= self.pause_below_spin.value():
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Invalid thresholds",
|
||||
"'Resume above' must be greater than 'Pause below' (hysteresis gap).",
|
||||
)
|
||||
return
|
||||
self.accept()
|
||||
|
||||
def result_settings(self) -> GuardSettings:
|
||||
"""Valid after `exec_()` returns `QDialog.Accepted`."""
|
||||
return GuardSettings(
|
||||
enabled=self.enabled_check.isChecked(),
|
||||
device_name=self.device_combo.currentText() or None,
|
||||
pause_below=self.pause_below_spin.value(),
|
||||
resume_above=self.resume_above_spin.value(),
|
||||
)
|
||||
@@ -0,0 +1,629 @@
|
||||
"""
|
||||
The dialog behind the schedule widget's "Add..."/"Edit..." buttons.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||
BECDeviceFilter,
|
||||
DeviceComboBox,
|
||||
)
|
||||
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl, ScanParameterConfig
|
||||
|
||||
# pylint: disable=E0611
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QScrollArea,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .qt_widgets import MyButton
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# Beamlines that get the XAS scan control, the Digital Twin item, and the
|
||||
# beamline-specific quick-fill items (Absorber, Ionization Chamber,
|
||||
# Reference foil changer, Auto-Gain).
|
||||
_XAS_BEAMLINES = ("x01da", "x10da")
|
||||
|
||||
|
||||
class ScheduleItemDialog(QDialog):
|
||||
"""Add or edit one schedule item, via ScanControl, a move form, Digital Twin, or free text."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scans,
|
||||
dev,
|
||||
parent=None,
|
||||
initial: dict | None = None,
|
||||
client=None,
|
||||
beamline: str | None = None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Schedule item")
|
||||
self.setMinimumSize(520, 480)
|
||||
|
||||
self._scans = scans
|
||||
self._dev = dev
|
||||
self._client = client
|
||||
|
||||
self.beamline = beamline
|
||||
self._is_xas_beamline = self.beamline in _XAS_BEAMLINES
|
||||
|
||||
if self._is_xas_beamline:
|
||||
logger.info(f"Loading bl-specific modules for beamline {self.beamline}")
|
||||
from ....bec_ipython_client.plugins.digital_twin_core.digital_twin_core import (
|
||||
DigitalTwinCore,
|
||||
)
|
||||
from ..auto_gain.auto_gain import AutoGain
|
||||
from ..digital_twin.digital_twin import DigitalTwin
|
||||
from ..edge_selector import EdgeSelector
|
||||
from ..scan_control_xas.scan_control_xas import ScanControlXAS
|
||||
|
||||
self.DigitalTwinCore = DigitalTwinCore
|
||||
self.DigitalTwin = DigitalTwin
|
||||
self.AutoGain = AutoGain
|
||||
self.EdgeSelector = EdgeSelector
|
||||
self.ScanControlXAS = ScanControlXAS
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.item_combo = QComboBox()
|
||||
layout.addWidget(self.item_combo)
|
||||
|
||||
self.stack = QStackedWidget()
|
||||
layout.addWidget(self.stack)
|
||||
|
||||
self._build_scan_page()
|
||||
self._build_move_page()
|
||||
if self._is_xas_beamline:
|
||||
self._build_digital_twin_page()
|
||||
self._build_abs_page()
|
||||
self._build_ic_page()
|
||||
self._build_reffoil_page()
|
||||
self._build_auto_gain_page()
|
||||
self._build_other_page()
|
||||
|
||||
self.item_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
|
||||
|
||||
# Shared across all items (not per-page), since it's a property
|
||||
# of the schedule item, not of any one form.
|
||||
self.pause_on_failure_check = QCheckBox("Pause schedule if item execution fails")
|
||||
layout.addWidget(self.pause_on_failure_check)
|
||||
|
||||
self.item_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
|
||||
|
||||
# Dispatch table keyed by the dropdown item's data (see
|
||||
# _add_page) - one place to register a new item type instead of
|
||||
# scattering per-item checks through _collect_result().
|
||||
self._collectors = {
|
||||
"scan": self._collect_scan_result,
|
||||
"move": self._collect_move_result,
|
||||
"digital_twin": self._collect_digital_twin_result,
|
||||
"abs": self._collect_abs_result,
|
||||
"ic": self._collect_ic_result,
|
||||
"reffoil": self._collect_reffoil_result,
|
||||
"auto_gain": self._collect_auto_gain_result,
|
||||
"other": self._collect_custom_result,
|
||||
}
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
buttons.button(QDialogButtonBox.Ok).setText("Add")
|
||||
buttons.setStyleSheet("QPushButton {qproperty-icon: none;}")
|
||||
buttons.accepted.connect(self._on_accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
# The embedded DigitalTwin instance runs a 1s polling timer and a
|
||||
# bec_dispatcher subscription for its whole lifetime - stop both
|
||||
# when this dialog closes, however it closes (OK, Cancel, or the
|
||||
# window's own close button), not just on accept.
|
||||
self.finished.connect(self._cleanup_digital_twin)
|
||||
|
||||
self._apply_initial(initial or {})
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Dropdown/page plumbing
|
||||
# ------------------------------------------------------------------ #
|
||||
def _add_page(self, key: str, label: str, widget: QWidget):
|
||||
# The combo and the stack are always appended to together, so a
|
||||
# combo index doubles as a stack index - no separate key->index
|
||||
# map needed. `key` is what _collect_result()/_apply_initial()
|
||||
# dispatch on.
|
||||
self.item_combo.addItem(label, key)
|
||||
self.stack.addWidget(widget)
|
||||
|
||||
def _select_page(self, key: str) -> bool:
|
||||
"""Switch the dropdown to `key`'s page; False if that item isn't available."""
|
||||
idx = self.item_combo.findData(key)
|
||||
if idx < 0:
|
||||
return False
|
||||
self.item_combo.setCurrentIndex(idx)
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# ScanControl - embeds BEC's own ScanControl widget
|
||||
# ------------------------------------------------------------------ #
|
||||
def _build_scan_page(self):
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
|
||||
# client=None resolves to the same process-wide BEC client
|
||||
# (bec_dispatcher.client) our own widget uses - no second Redis
|
||||
# connection is opened.
|
||||
if self._is_xas_beamline:
|
||||
self.scan_control = self.ScanControlXAS(parent=page, client=self._client)
|
||||
else:
|
||||
self.scan_control = ScanControl(parent=page, client=self._client)
|
||||
self.scan_control.button_run_scan.hide()
|
||||
layout.addWidget(self.scan_control)
|
||||
|
||||
self._add_page("scan", "ScanControl", page)
|
||||
|
||||
def _collect_scan_result(self) -> dict:
|
||||
# Same call ScanControl.run_scan() makes before actually
|
||||
# submitting, to resolve a typed-but-unconfirmed scan name.
|
||||
self.scan_control.validate_scan_selection()
|
||||
scan_name = self.scan_control.current_scan
|
||||
if not scan_name:
|
||||
raise ValueError("No scan selected.")
|
||||
|
||||
# bec_object=False: plain, repr-able values (e.g. a device name
|
||||
# string rather than the live DeviceBase instance) - needed since
|
||||
# the result has to survive a round-trip through Redis as text and
|
||||
# be re-evaluated later, not just used in-process immediately.
|
||||
args, kwargs = self.scan_control.get_scan_parameters(bec_object=False)
|
||||
command = _format_scan_call(scan_name, args, kwargs)
|
||||
return {
|
||||
"command": command,
|
||||
"kind": "scan",
|
||||
"form_state": {"scan_name": scan_name, "args": args, "kwargs": kwargs},
|
||||
}
|
||||
|
||||
def _prefill_scan_page(self, scan_name: str, args: list, kwargs: dict):
|
||||
# ScanControl restores parameters for a scan from its own config
|
||||
# cache (see `ScanControl.restore_scan_parameters`); pre-loading
|
||||
# that cache before switching to the scan reuses that mechanism
|
||||
# instead of poking at its internal argument widgets directly.
|
||||
self.scan_control.config.scans[scan_name] = ScanParameterConfig(
|
||||
name=scan_name, args=args, kwargs=kwargs
|
||||
)
|
||||
self.scan_control.current_scan = scan_name
|
||||
self.scan_control.restore_scan_parameters(scan_name)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Move
|
||||
# ------------------------------------------------------------------ #
|
||||
def _build_move_page(self):
|
||||
page = QWidget()
|
||||
form = QFormLayout(page)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
|
||||
|
||||
self.move_device_combo = DeviceComboBox(self, device_filter=[BECDeviceFilter.POSITIONER])
|
||||
form.addRow("Device", self.move_device_combo)
|
||||
|
||||
self.move_value_spin = QDoubleSpinBox()
|
||||
form.addRow("Value", self.move_value_spin)
|
||||
|
||||
self.move_relative_check = QCheckBox("")
|
||||
form.addRow("Relative move", self.move_relative_check)
|
||||
|
||||
self.move_device_combo.currentIndexChanged.connect(self._adjust_spinbox)
|
||||
|
||||
self._add_page("move", "Move", page)
|
||||
|
||||
def _adjust_spinbox(self, _) -> None:
|
||||
device = self._dev[self.move_device_combo.currentText()]
|
||||
prec = device.precision
|
||||
units = device.egu()
|
||||
ll = device.low_limit
|
||||
hl = device.high_limit
|
||||
|
||||
self.move_value_spin.setDecimals(prec)
|
||||
self.move_value_spin.setSuffix(f" {units}")
|
||||
if (hl - ll) > 0:
|
||||
self.move_value_spin.setMinimum(ll)
|
||||
self.move_value_spin.setMaximum(hl)
|
||||
self.move_value_spin.setSingleStep(10 ** round(math.log10((hl - ll) / 100)))
|
||||
else:
|
||||
self.move_value_spin.setMinimum(1e6)
|
||||
self.move_value_spin.setMaximum(-1e6)
|
||||
self.move_value_spin.setSingleStep(1)
|
||||
|
||||
def _collect_move_result(self) -> dict:
|
||||
device_name = self.move_device_combo.currentText()
|
||||
if not device_name:
|
||||
raise ValueError("No movable device available/selected.")
|
||||
value = self.move_value_spin.value()
|
||||
units = self.move_value_spin.suffix()
|
||||
relative = self.move_relative_check.isChecked()
|
||||
return {
|
||||
"command": f"dev.{device_name}.move({value!r}, relative={relative!r})",
|
||||
"kind": "move",
|
||||
"form_state": {
|
||||
"device_name": device_name,
|
||||
"value": value,
|
||||
"units": units,
|
||||
"relative": relative,
|
||||
},
|
||||
}
|
||||
|
||||
def _prefill_move_page(self, state: dict):
|
||||
idx = self.move_device_combo.findText(state.get("device_name", ""))
|
||||
if idx >= 0:
|
||||
self.move_device_combo.setCurrentIndex(idx)
|
||||
self.move_value_spin.setValue(float(state.get("value", 0.0)))
|
||||
self.move_relative_check.setChecked(bool(state.get("relative", False)))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Digital Twin - embeds the beamline-alignment DigitalTwin widget
|
||||
# ------------------------------------------------------------------ #
|
||||
def _build_digital_twin_page(self):
|
||||
page = QWidget()
|
||||
outer = QVBoxLayout(page)
|
||||
|
||||
# DigitalTwin defaults to a large fixed size (it's normally its
|
||||
# own top-level window) - wrap it in a scroll area so this dialog
|
||||
# doesn't have to grow to match it.
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
self.digital_twin = self.DigitalTwin(parent=scroll, client=self._client)
|
||||
# Hide move and abs open buttons
|
||||
for mover in self.digital_twin.mover.mover_widgets:
|
||||
mover.btn_action.hide()
|
||||
self.digital_twin.mover.abs.btn_action.hide()
|
||||
|
||||
scroll.setWidget(self.digital_twin)
|
||||
outer.addWidget(scroll)
|
||||
|
||||
hint = QLabel(
|
||||
"Configure the beamline alignment above, then confirm with Add below - the "
|
||||
"computed motor targets are captured now and moved together (in one combined "
|
||||
"move) when this schedule item runs, not immediately."
|
||||
)
|
||||
hint.setWordWrap(True)
|
||||
hint.setStyleSheet("color: gray;")
|
||||
outer.addWidget(hint)
|
||||
|
||||
self._add_page("digital_twin", "Digital Twin", page)
|
||||
|
||||
def _collect_digital_twin_result(self) -> dict:
|
||||
# A frozen snapshot of the configured targets, not a live handle -
|
||||
# editing the Digital Twin widget afterwards must not affect an
|
||||
# already-added schedule item (see module docstring).
|
||||
config = self.digital_twin.get_assistant_config()
|
||||
cmd = f"digital_twin.move_with_config({config})"
|
||||
return {
|
||||
"command": cmd,
|
||||
"kind": "custom",
|
||||
"form_state": {"text": cmd, "source": "digital_twin", "config": config},
|
||||
}
|
||||
|
||||
def _cleanup_digital_twin(self, *_):
|
||||
digital_twin = getattr(self, "digital_twin", None)
|
||||
if digital_twin is None:
|
||||
return
|
||||
try:
|
||||
digital_twin._timer.stop() # pylint: disable=protected-access
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Failed to stop the Digital Twin's reality-update timer.")
|
||||
try:
|
||||
digital_twin.cleanup()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Failed to clean up the embedded Digital Twin widget.")
|
||||
|
||||
def _prefill_digital_twin_page(self, state: dict):
|
||||
config = state["config"]
|
||||
self.digital_twin.set_assistant_config(config)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Absorber
|
||||
# ------------------------------------------------------------------ #
|
||||
def _build_abs_page(self):
|
||||
if "abs" not in self._dev:
|
||||
return
|
||||
page = QWidget()
|
||||
form = QFormLayout(page)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
|
||||
|
||||
self.abs_selector = QComboBox()
|
||||
self.abs_selector.addItems(["Open", "Force open", "Close"])
|
||||
form.addRow("Action", self.abs_selector)
|
||||
|
||||
self._add_page("abs", "Absorber", page)
|
||||
|
||||
def _collect_abs_result(self) -> dict:
|
||||
action = self.abs_selector.currentText()
|
||||
suffix = {"Open": "open()", "Force open": "open(force=True)", "Close": "close()"}.get(
|
||||
action
|
||||
)
|
||||
if suffix is None:
|
||||
raise ValueError("Select an absorber action.")
|
||||
cmd = f"dev.abs.{suffix}"
|
||||
return {
|
||||
"command": cmd,
|
||||
"kind": "custom",
|
||||
"form_state": {"text": cmd, "source": "abs", "action": action},
|
||||
}
|
||||
|
||||
def _prefill_abs_form(self, state: dict):
|
||||
idx = self.abs_selector.findText(state.get("action", ""))
|
||||
if idx >= 0:
|
||||
self.abs_selector.setCurrentIndex(idx)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Ionization chamber
|
||||
# ------------------------------------------------------------------ #
|
||||
def _build_ic_page(self):
|
||||
if not all(key in self._dev for key in ("ic0", "ic1", "ic2")):
|
||||
return
|
||||
page = QWidget()
|
||||
form = QFormLayout(page)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
|
||||
|
||||
self.ic_selector = QComboBox()
|
||||
self.ic_selector.addItems(["IC0", "IC1", "IC2"])
|
||||
|
||||
gases = ["He", "N2", "Ar", "Kr"]
|
||||
self.gas1 = QComboBox()
|
||||
self.gas2 = QComboBox()
|
||||
self.gas1.addItems(gases)
|
||||
self.gas2.addItems(gases)
|
||||
|
||||
self.conc1 = QDoubleSpinBox()
|
||||
self.conc2 = QDoubleSpinBox()
|
||||
for conc in (self.conc1, self.conc2):
|
||||
conc.setDecimals(0)
|
||||
conc.setSuffix(" %")
|
||||
conc.setMinimum(0)
|
||||
conc.setMaximum(100)
|
||||
conc.setSingleStep(1)
|
||||
|
||||
self.pressure = QDoubleSpinBox()
|
||||
self.pressure.setDecimals(3)
|
||||
self.pressure.setSuffix(" bar abs")
|
||||
self.pressure.setMinimum(1)
|
||||
self.pressure.setMaximum(3)
|
||||
self.pressure.setSingleStep(0.1)
|
||||
|
||||
form.addRow("Ionization chamber", self.ic_selector)
|
||||
form.addRow("Gas 1", self.gas1)
|
||||
form.addRow("Concentration 1", self.conc1)
|
||||
form.addRow("Gas 2", self.gas2)
|
||||
form.addRow("Concentration 2", self.conc2)
|
||||
form.addRow("Pressure", self.pressure)
|
||||
|
||||
self.conc1.valueChanged.connect(self._equalize_ic_conc)
|
||||
self.conc2.valueChanged.connect(self._equalize_ic_conc)
|
||||
|
||||
self._add_page("ic", "Ionization Chamber", page)
|
||||
|
||||
def _equalize_ic_conc(self, new_val):
|
||||
if self.conc1.value() == new_val: # conc1 was changed
|
||||
self.conc2.setValue(100 - new_val)
|
||||
else:
|
||||
self.conc1.setValue(100 - new_val)
|
||||
|
||||
def _collect_ic_result(self) -> dict:
|
||||
total = self.conc1.value() + self.conc2.value()
|
||||
if total != 100:
|
||||
raise ValueError(f"Gas concentrations must add up to 100% (currently {total:.0f}%).")
|
||||
chamber = self.ic_selector.currentText()
|
||||
ic = {"IC0": "ic0", "IC1": "ic1", "IC2": "ic2"}[chamber]
|
||||
cmd = (
|
||||
f"dev.{ic}.fill("
|
||||
f"gas1='{self.gas1.currentText()}', conc1={self.conc1.value()}, "
|
||||
f"gas2='{self.gas2.currentText()}', conc2={self.conc2.value()}, "
|
||||
f"pressure={self.pressure.value()}, wait=True)"
|
||||
)
|
||||
return {
|
||||
"command": cmd,
|
||||
"kind": "custom",
|
||||
"form_state": {
|
||||
"text": cmd,
|
||||
"source": "ic",
|
||||
"chamber": chamber,
|
||||
"gas1": self.gas1.currentText(),
|
||||
"conc1": self.conc1.value(),
|
||||
"gas2": self.gas2.currentText(),
|
||||
"conc2": self.conc2.value(),
|
||||
"pressure": self.pressure.value(),
|
||||
},
|
||||
}
|
||||
|
||||
def _prefill_ic_form(self, state: dict):
|
||||
idx = self.ic_selector.findText(state.get("chamber", ""))
|
||||
if idx >= 0:
|
||||
self.ic_selector.setCurrentIndex(idx)
|
||||
idx = self.gas1.findText(state.get("gas1", ""))
|
||||
if idx >= 0:
|
||||
self.gas1.setCurrentIndex(idx)
|
||||
idx = self.gas2.findText(state.get("gas2", ""))
|
||||
if idx >= 0:
|
||||
self.gas2.setCurrentIndex(idx)
|
||||
# Set conc2 last: conc1's valueChanged->_equalize_ic_conc would
|
||||
# otherwise immediately overwrite it.
|
||||
self.conc1.setValue(float(state.get("conc1", 0)))
|
||||
self.conc2.setValue(float(state.get("conc2", 0)))
|
||||
self.pressure.setValue(float(state.get("pressure", self.pressure.minimum())))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Reference foil changer
|
||||
# ------------------------------------------------------------------ #
|
||||
def _build_reffoil_page(self):
|
||||
if "reffoilchanger" not in self._dev:
|
||||
return
|
||||
page = QWidget()
|
||||
form = QFormLayout(page)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
|
||||
|
||||
self.reffoil_selector = QComboBox()
|
||||
self.reffoil_selector.addItems(self._dev.reffoilchanger.get_all_foils())
|
||||
form.addRow("Reference foil", self.reffoil_selector)
|
||||
|
||||
self._add_page("reffoil", "Reference foil changer", page)
|
||||
|
||||
def _collect_reffoil_result(self) -> dict:
|
||||
foil = self.reffoil_selector.currentText()
|
||||
if not foil:
|
||||
raise ValueError("Select a reference foil.")
|
||||
cmd = f"dev.reffoilchanger.insert(ref='{foil}', wait=True)"
|
||||
return {
|
||||
"command": cmd,
|
||||
"kind": "custom",
|
||||
"form_state": {"text": cmd, "source": "reffoil", "foil": foil},
|
||||
}
|
||||
|
||||
def _prefill_reffoil_form(self, state: dict):
|
||||
idx = self.reffoil_selector.findText(state.get("foil", ""))
|
||||
if idx >= 0:
|
||||
self.reffoil_selector.setCurrentIndex(idx)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Auto-Gain
|
||||
# ------------------------------------------------------------------ #
|
||||
def _build_auto_gain_page(self):
|
||||
page = QWidget()
|
||||
outer = QVBoxLayout(page)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
self.auto_gain = self.AutoGain(show_control=False, parent=scroll, client=self._client)
|
||||
|
||||
scroll.setWidget(self.auto_gain)
|
||||
outer.addWidget(scroll)
|
||||
|
||||
hint = QLabel(
|
||||
"Configure the auto-gain functionality, then confirm with Add below - the "
|
||||
"auto-gain will only be executed when this schedule item runs, not immediately."
|
||||
)
|
||||
hint.setWordWrap(True)
|
||||
hint.setStyleSheet("color: gray;")
|
||||
outer.addWidget(hint)
|
||||
|
||||
self._add_page("auto_gain", "Auto Gain", page)
|
||||
|
||||
def _collect_auto_gain_result(self) -> dict:
|
||||
config = self.auto_gain.get_config()
|
||||
|
||||
cmd = (
|
||||
f"auto_gain.start(element='{config['element']}', edge='{config['edge']}', "
|
||||
+ f"amplifier={config['all_amplifiers']}, link_ic0_ic1={config['link_ic0_ic1']}, "
|
||||
+ f"comp_ring_current= {config['compensate_ring_current']}, "
|
||||
+ f"prohibited_gains={config['prohibited_gains']})"
|
||||
)
|
||||
return {
|
||||
"command": cmd,
|
||||
"kind": "custom",
|
||||
"form_state": {"text": cmd, "source": "auto_gain", "config": config},
|
||||
}
|
||||
|
||||
def _prefill_auto_gain_form(self, state: dict):
|
||||
self.auto_gain.load_config(state["config"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Other
|
||||
# ------------------------------------------------------------------ #
|
||||
def _build_other_page(self):
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.addWidget(
|
||||
QLabel(
|
||||
"Free-form command, evaluated against `scans` and `dev` - use this for "
|
||||
"anything the other items don't cover, e.g. an RPC call to another widget."
|
||||
)
|
||||
)
|
||||
self.custom_edit = QLineEdit()
|
||||
# TODO Change to a different placeholder text
|
||||
self.custom_edit.setPlaceholderText("scans.xas_simple_scan(12000, 14000, 2, 10)")
|
||||
layout.addWidget(self.custom_edit)
|
||||
layout.addStretch(1)
|
||||
|
||||
self._add_page("other", "Other", page)
|
||||
|
||||
def _collect_custom_result(self) -> dict:
|
||||
text = self.custom_edit.text().strip()
|
||||
if not text:
|
||||
raise ValueError("Command must not be empty.")
|
||||
return {"command": text, "kind": "custom", "form_state": {"text": text}}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# pre-fill (edit mode) / result extraction
|
||||
# ------------------------------------------------------------------ #
|
||||
def _apply_initial(self, initial: dict):
|
||||
self.pause_on_failure_check.setChecked(bool(initial.get("pause_on_failure", False)))
|
||||
|
||||
logger.info(f"Got initial: {initial}")
|
||||
|
||||
kind = initial.get("kind")
|
||||
state = initial.get("form_state") or {}
|
||||
source = state.get("source")
|
||||
|
||||
if kind == "scan" and state.get("scan_name"):
|
||||
self._prefill_scan_page(
|
||||
state["scan_name"], state.get("args") or [], state.get("kwargs") or {}
|
||||
)
|
||||
self._select_page("scan")
|
||||
elif kind == "custom" and source == "digital_twin" and self._select_page("digital_twin"):
|
||||
self._prefill_digital_twin_page(state)
|
||||
elif kind == "move" and state.get("device_name"):
|
||||
self._prefill_move_page(state)
|
||||
self._select_page("move")
|
||||
elif kind == "custom" and source == "abs" and self._select_page("abs"):
|
||||
self._prefill_abs_form(state)
|
||||
elif kind == "custom" and source == "ic" and self._select_page("ic"):
|
||||
self._prefill_ic_form(state)
|
||||
elif kind == "custom" and source == "reffoil" and self._select_page("reffoil"):
|
||||
self._prefill_reffoil_form(state)
|
||||
elif kind == "custom" and source == "auto_gain" and self._select_page("auto_gain"):
|
||||
self._prefill_auto_gain_form(state)
|
||||
elif initial.get("command"):
|
||||
# "custom" kind with no (recognized/available) source, or a
|
||||
# legacy/unrecognized item - fall back to showing the raw
|
||||
# command text as-is.
|
||||
self.custom_edit.setText(state.get("text", initial["command"]))
|
||||
self._select_page("other")
|
||||
else:
|
||||
logger.warning(f"Unknown kind: {kind}")
|
||||
|
||||
def _on_accept(self):
|
||||
try:
|
||||
result = self._collect_result()
|
||||
except ValueError as exc:
|
||||
QMessageBox.warning(self, "Can't build command", str(exc))
|
||||
return
|
||||
self._result = result
|
||||
self.accept()
|
||||
|
||||
def _collect_result(self) -> dict:
|
||||
key = self.item_combo.currentData()
|
||||
handler = self._collectors.get(key)
|
||||
if handler is None:
|
||||
raise ValueError(f"Unknown item type: {key!r}")
|
||||
result = handler()
|
||||
result["pause_on_failure"] = self.pause_on_failure_check.isChecked()
|
||||
return result
|
||||
|
||||
def result(self) -> dict:
|
||||
"""Valid after `exec_()` returns `QDialog.Accepted`."""
|
||||
return self._result
|
||||
|
||||
|
||||
def _format_scan_call(scan_name: str, args: list, kwargs: dict) -> str:
|
||||
parts = [repr(a) for a in args]
|
||||
parts += [f"{name}={value!r}" for name, value in kwargs.items()]
|
||||
return f"scans.{scan_name}({', '.join(parts)})"
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Settings dialog for `notifications.NotificationSettings` - kept separate
|
||||
from the send logic itself (`notifications.py`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from .enums import ScheduleItemStatus
|
||||
from .notifications import NotificationSettings, NotificationTarget, send_notification
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def _service_state(client, name: str) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
Best-effort read of a messaging service's enabled/scopes state (see
|
||||
the module docstring in notifications.py for why this is the only
|
||||
introspection available). Never raises - a client without `messaging`
|
||||
set up yet just looks like "not enabled, no scopes".
|
||||
"""
|
||||
messaging = getattr(client, "messaging", None)
|
||||
service = getattr(messaging, name, None) if messaging is not None else None
|
||||
if service is None:
|
||||
return False, []
|
||||
return bool(getattr(service, "_enabled", False)), sorted(getattr(service, "_scopes", set()))
|
||||
|
||||
|
||||
class NotificationSettingsDialog(QDialog):
|
||||
"""Choose whether/where/for-which-item-types finish/fail notifications are sent."""
|
||||
|
||||
def __init__(self, settings: NotificationSettings, client, parent=None):
|
||||
super().__init__(parent)
|
||||
self._client = client
|
||||
self.setWindowTitle("Notifications")
|
||||
self.setMinimumWidth(380)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
info_label = QLabel(
|
||||
"Sends a message through the selected BEC messaging service whenever a "
|
||||
"schedule item finishes or fails."
|
||||
)
|
||||
info_label.setWordWrap(True)
|
||||
layout.addWidget(info_label)
|
||||
|
||||
self.enabled_check = QCheckBox("Send notifications")
|
||||
self.enabled_check.setChecked(settings.enabled)
|
||||
layout.addWidget(self.enabled_check)
|
||||
|
||||
kind_box = QGroupBox("Notify for")
|
||||
kind_layout = QVBoxLayout(kind_box)
|
||||
self.scan_check = QCheckBox("Scans")
|
||||
self.scan_check.setChecked(settings.notify_scan)
|
||||
self.move_check = QCheckBox("Movements")
|
||||
self.move_check.setChecked(settings.notify_move)
|
||||
self.rpc_check = QCheckBox("RPC / custom commands")
|
||||
self.rpc_check.setChecked(settings.notify_rpc)
|
||||
for check in (self.scan_check, self.move_check, self.rpc_check):
|
||||
kind_layout.addWidget(check)
|
||||
layout.addWidget(kind_box)
|
||||
|
||||
target_box = QGroupBox("Send via")
|
||||
target_layout = QVBoxLayout(target_box)
|
||||
self.scilog_check = QCheckBox("SciLog")
|
||||
self.teams_check = QCheckBox("Microsoft Teams")
|
||||
self.signal_check = QCheckBox("Signal Messenger")
|
||||
for check in (self.scilog_check, self.teams_check, self.signal_check):
|
||||
target_layout.addWidget(check)
|
||||
layout.addWidget(target_box)
|
||||
|
||||
event_box = QGroupBox("Send on")
|
||||
event_layout = QVBoxLayout(event_box)
|
||||
self.completed_check = QCheckBox("Completed")
|
||||
self.aborted_check = QCheckBox("Aborted")
|
||||
self.failed_check = QCheckBox("Failed")
|
||||
for check in (self.completed_check, self.aborted_check, self.failed_check):
|
||||
event_layout.addWidget(check)
|
||||
layout.addWidget(event_box)
|
||||
|
||||
# Teams: contact/channel can only be a scope an admin has already
|
||||
# registered for the deployment - there's no way to address an
|
||||
# arbitrary Teams user from the client, so this is a dropdown, not
|
||||
# free text (see notifications.py's module docstring).
|
||||
self.teams_box = QGroupBox("Teams settings")
|
||||
teams_layout = QVBoxLayout(self.teams_box)
|
||||
teams_enabled, teams_scopes = _service_state(client, "teams")
|
||||
self.teams_combo = QComboBox()
|
||||
self.teams_combo.addItems(teams_scopes)
|
||||
self.teams_combo.setEnabled(teams_enabled)
|
||||
teams_layout.addWidget(QLabel("Contact or channel"))
|
||||
teams_layout.addWidget(self.teams_combo)
|
||||
if not teams_enabled:
|
||||
teams_layout.addWidget(_dim_label("Teams messaging is not enabled for this session."))
|
||||
elif not teams_scopes:
|
||||
teams_layout.addWidget(_dim_label("No Teams contacts/channels are registered."))
|
||||
layout.addWidget(self.teams_box)
|
||||
|
||||
# Signal: a raw phone number works directly (BEC normalizes it -
|
||||
# see the docs), so this is an editable combo box: type a number,
|
||||
# or pick a pre-registered scope if any exist.
|
||||
self.signal_box = QGroupBox("Signal settings")
|
||||
signal_layout = QVBoxLayout(self.signal_box)
|
||||
signal_enabled, signal_scopes = _service_state(client, "signal")
|
||||
self.signal_combo = QComboBox()
|
||||
self.signal_combo.setEditable(True)
|
||||
self.signal_combo.addItems(signal_scopes)
|
||||
self.signal_combo.setEnabled(signal_enabled)
|
||||
signal_layout.addWidget(QLabel("Phone number or contact"))
|
||||
self.signal_combo.setEditText("")
|
||||
self.signal_combo.lineEdit().setPlaceholderText("e.g. +41791234567")
|
||||
signal_layout.addWidget(self.signal_combo)
|
||||
if not signal_enabled:
|
||||
signal_layout.addWidget(_dim_label("Signal messaging is not enabled for this session."))
|
||||
layout.addWidget(self.signal_box)
|
||||
|
||||
# pre-fill from the persisted settings
|
||||
{
|
||||
NotificationTarget.SCILOG: self.scilog_check,
|
||||
NotificationTarget.TEAMS: self.teams_check,
|
||||
NotificationTarget.SIGNAL: self.signal_check,
|
||||
}.get(settings.target, self.scilog_check).setChecked(True)
|
||||
for event in settings.events:
|
||||
{
|
||||
ScheduleItemStatus.COMPLETED: self.completed_check,
|
||||
ScheduleItemStatus.ABORTED: self.aborted_check,
|
||||
ScheduleItemStatus.FAILED: self.failed_check,
|
||||
}.get(event).setChecked(True)
|
||||
if settings.teams_scope:
|
||||
idx = self.teams_combo.findText(settings.teams_scope)
|
||||
if idx >= 0:
|
||||
self.teams_combo.setCurrentIndex(idx)
|
||||
if settings.signal_number:
|
||||
self.signal_combo.setEditText(settings.signal_number)
|
||||
|
||||
self.scilog_check.toggled.connect(lambda *_: self._sync_target_widgets(self.scilog_check))
|
||||
self.teams_check.toggled.connect(lambda *_: self._sync_target_widgets(self.teams_check))
|
||||
self.signal_check.toggled.connect(lambda *_: self._sync_target_widgets(self.signal_check))
|
||||
|
||||
self.test_btn = QPushButton("Send test message")
|
||||
self.test_btn.clicked.connect(self._on_test_clicked)
|
||||
layout.addWidget(self.test_btn)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
self._sync_target_widgets()
|
||||
|
||||
def _sync_target_widgets(self, current_button: QCheckBox = None):
|
||||
if current_button is None:
|
||||
for button in [self.scilog_check, self.teams_check, self.signal_check]:
|
||||
if button.isChecked():
|
||||
current_button = button
|
||||
break
|
||||
if current_button.isChecked():
|
||||
for button in [self.scilog_check, self.teams_check, self.signal_check]:
|
||||
if button != current_button:
|
||||
button.setChecked(False)
|
||||
if current_button == self.teams_check:
|
||||
self.teams_box.setVisible(True)
|
||||
self.signal_box.setVisible(False)
|
||||
elif current_button == self.signal_check:
|
||||
self.teams_box.setVisible(False)
|
||||
self.signal_box.setVisible(True)
|
||||
else:
|
||||
self.teams_box.setVisible(False)
|
||||
self.signal_box.setVisible(False)
|
||||
self.layout().activate()
|
||||
self.adjustSize()
|
||||
|
||||
def _current_settings(self) -> NotificationSettings:
|
||||
if self.scilog_check.isChecked():
|
||||
target = NotificationTarget.SCILOG
|
||||
elif self.teams_check.isChecked():
|
||||
target = NotificationTarget.TEAMS
|
||||
elif self.signal_check.isChecked():
|
||||
target = NotificationTarget.SIGNAL
|
||||
else:
|
||||
raise ValueError(f"Target {target} not supported")
|
||||
|
||||
events = []
|
||||
if self.completed_check.isChecked():
|
||||
events.append(ScheduleItemStatus.COMPLETED)
|
||||
if self.aborted_check.isChecked():
|
||||
events.append(ScheduleItemStatus.ABORTED)
|
||||
if self.failed_check.isChecked():
|
||||
events.append(ScheduleItemStatus.FAILED)
|
||||
|
||||
return NotificationSettings(
|
||||
enabled=self.enabled_check.isChecked(),
|
||||
notify_scan=self.scan_check.isChecked(),
|
||||
notify_move=self.move_check.isChecked(),
|
||||
notify_rpc=self.rpc_check.isChecked(),
|
||||
target=target,
|
||||
events=events,
|
||||
teams_scope=self.teams_combo.currentText().strip() or None,
|
||||
signal_number=self.signal_combo.currentText().strip() or None,
|
||||
)
|
||||
|
||||
def _on_test_clicked(self):
|
||||
settings = self._current_settings()
|
||||
try:
|
||||
send_notification(self._client, settings, "Test message from the BEC schedule widget.")
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
QMessageBox.critical(self, "Test message failed", str(exc))
|
||||
return
|
||||
QMessageBox.information(self, "Test message sent", "The test message was sent.")
|
||||
|
||||
def result_settings(self) -> NotificationSettings:
|
||||
"""Valid after `exec_()` returns `QDialog.Accepted`."""
|
||||
return self._current_settings()
|
||||
|
||||
|
||||
def _dim_label(text: str) -> QLabel:
|
||||
label = QLabel(text)
|
||||
label.setStyleSheet("color: gray;")
|
||||
label.setWordWrap(True)
|
||||
return label
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
A small, self-contained "tell the user when something finished/failed"
|
||||
feature, independent of scheduling/execution logic.
|
||||
|
||||
Sends through BEC's own `client.messaging` container
|
||||
(`bec_lib.messaging_services.MessagingContainer`, exposed as `bec.messaging`
|
||||
in the IPython client) - the same mechanism documented at
|
||||
https://bec.readthedocs.io/latest/how-to/general/send-messages-to-signal.html
|
||||
and
|
||||
https://bec.readthedocs.io/latest/how-to/general/send-messages-to-scilog.html:
|
||||
|
||||
bec.messaging.signal.new("Beamline checks completed.").send(scope="+41791234567")
|
||||
bec.messaging.scilog.new("Beamline checks completed.").send()
|
||||
bec.messaging.teams.new("Beamline checks completed.").send(scope=<registered scope>)
|
||||
|
||||
Notes on each service, read directly from `bec_lib.messaging_services`
|
||||
rather than assumed:
|
||||
|
||||
- **SciLog** needs no `scope` - it posts to the logbook of the currently
|
||||
active pgroup automatically.
|
||||
- **Signal** accepts a raw phone number as `scope` (BEC normalizes it,
|
||||
defaulting to the Swiss country code for numbers without one - see the
|
||||
docs above) *or* a pre-registered scope name for a group.
|
||||
- **Teams** is a plain `MessagingService` with no Teams-specific methods:
|
||||
a contact/channel can only be addressed by a `scope` an admin has
|
||||
already registered for the deployment (`service._scopes`) - there is no
|
||||
way to address an arbitrary Teams user/email directly from the client.
|
||||
This is why the Teams UI in `notification_dialog.py` is a dropdown of
|
||||
known scopes rather than free text, while Signal's is free text (with
|
||||
any registered scopes offered as suggestions).
|
||||
|
||||
`client.messaging.<service>._enabled` / `._scopes` are the only
|
||||
introspection available (no public accessor exists at the time of
|
||||
writing) - used here and in the dialog to grey out a target that isn't
|
||||
configured for the current session instead of only failing at send time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .enums import ScheduleItemStatus
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class NotificationTarget(str, Enum):
|
||||
SCILOG = "scilog"
|
||||
TEAMS = "teams"
|
||||
SIGNAL = "signal"
|
||||
|
||||
|
||||
class NotificationSettings(BaseModel):
|
||||
"""Persisted per-schedule notification preferences."""
|
||||
|
||||
enabled: bool = False
|
||||
notify_scan: bool = True
|
||||
notify_move: bool = True
|
||||
# "custom" is what this plugin calls the free-text tab (see
|
||||
# item_dialog.py) - it covers RPC calls to other widgets as well as
|
||||
# any hand-typed command, so it's exposed to the user as "RPC".
|
||||
notify_rpc: bool = True
|
||||
|
||||
target: NotificationTarget = NotificationTarget.SCILOG
|
||||
events: list[ScheduleItemStatus] = []
|
||||
teams_scope: str | None = None # a pre-registered Teams scope (channel/contact)
|
||||
signal_number: str | None = None # a phone number, or a pre-registered Signal scope
|
||||
|
||||
|
||||
def _enabled_for_kind(settings: NotificationSettings, kind: str) -> bool:
|
||||
if not settings.enabled:
|
||||
return False
|
||||
return {
|
||||
"scan": settings.notify_scan,
|
||||
"move": settings.notify_move,
|
||||
"custom": settings.notify_rpc,
|
||||
}.get(kind, False)
|
||||
|
||||
|
||||
def send_notification(client, settings: NotificationSettings, text: str) -> None:
|
||||
"""
|
||||
Send `text` through whichever service `settings.target` selects.
|
||||
|
||||
Raises on failure (no target selected, missing Teams scope/Signal
|
||||
number, the target service not enabled for this session, ...) -
|
||||
callers that want a fire-and-forget send should catch around this.
|
||||
`notify_item_finished` does; the dialog's "Send test message" button
|
||||
deliberately does not, so the operator sees exactly what went wrong.
|
||||
"""
|
||||
if settings.target == NotificationTarget.SCILOG:
|
||||
client.messaging.scilog.new(text).send()
|
||||
elif settings.target == NotificationTarget.TEAMS:
|
||||
if not settings.teams_scope:
|
||||
raise ValueError("No Teams contact/channel selected.")
|
||||
client.messaging.teams.new(text).send(scope=settings.teams_scope)
|
||||
elif settings.target == NotificationTarget.SIGNAL:
|
||||
if not settings.signal_number:
|
||||
raise ValueError("No Signal phone number/contact set.")
|
||||
client.messaging.signal.new(text).send(scope=settings.signal_number)
|
||||
else:
|
||||
raise ValueError("No notification target selected.")
|
||||
|
||||
|
||||
def notify_item_finished(
|
||||
client,
|
||||
kind: str,
|
||||
command: str,
|
||||
scan_number: str,
|
||||
final_status: ScheduleItemStatus,
|
||||
settings: NotificationSettings,
|
||||
error: str | None = None,
|
||||
):
|
||||
"""
|
||||
Sends a message if notifications are enabled for `kind` and a target
|
||||
is configured. Never raises - a notification failing to send should
|
||||
not affect schedule execution.
|
||||
"""
|
||||
if not _enabled_for_kind(settings, kind):
|
||||
return
|
||||
if final_status not in settings.events:
|
||||
return
|
||||
|
||||
status_word = final_status.name
|
||||
text = f"**BEC Scheduler Widget**\nStatus: {kind} {status_word}"
|
||||
if scan_number is not None:
|
||||
text = text + f"\nScan number: {scan_number}"
|
||||
text = text + f"\n\nCommand: {command}"
|
||||
if error:
|
||||
text += f"\n\n{error.strip().splitlines()[-1]}"
|
||||
|
||||
try:
|
||||
send_notification(client, settings, text)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(f"Failed to send schedule notification via {settings.target.value}")
|
||||
|
||||
|
||||
def notify_schedule_state(client, settings, schedule_name, state):
|
||||
"""
|
||||
Sends a message if notifications are enabled and a schedule is started or stopped.
|
||||
Never raises - a notification failing to send should not affect schedule execution.
|
||||
"""
|
||||
text = f'**BEC Scheduler Widget**\nSchedule "{schedule_name}" '
|
||||
if state == "started":
|
||||
text = text + "has started"
|
||||
elif state == "aborted":
|
||||
text = text + "was aborted"
|
||||
elif state == "widget_closed":
|
||||
text = text + "has finished prematurely because widget was closed"
|
||||
elif state == "finished":
|
||||
text = text + "has finished"
|
||||
else:
|
||||
logger.warning(f"Unknown schedule state {state}")
|
||||
try:
|
||||
send_notification(client, settings, text)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(f"Failed to send schedule notification via {settings.target.value}")
|
||||
@@ -0,0 +1,159 @@
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from qtpy.QtCore import QModelIndex, QSize, Qt, Signal
|
||||
|
||||
# pylint: disable=E0611
|
||||
from qtpy.QtGui import QKeySequence
|
||||
from qtpy.QtWidgets import QHeaderView, QPushButton, QStyle, QStyledItemDelegate, QTableView
|
||||
|
||||
_ROW_MARGIN = 2 # total vertical padding (top+bottom) added around the text block
|
||||
|
||||
|
||||
class _MultilineTextDelegate(QStyledItemDelegate):
|
||||
"""
|
||||
Paints and sizes a cell whose DisplayRole text may contain literal
|
||||
'\\n' line breaks (schedule item labels are built with explicit line
|
||||
breaks, e.g. the digital-twin config summary). The base
|
||||
QStyledItemDelegate only reliably shows a single line, so without this
|
||||
a multi-line label got clipped once rows stopped auto-sizing the way
|
||||
QListWidgetItem used to. `sizeHint()` reports the full multi-line
|
||||
height so the view's ResizeToContents row mode can actually fit it.
|
||||
|
||||
Selection is painted here with `option.palette.highlight()` - the
|
||||
same approach BecLogTableView's `_LogCellDelegate` uses - so the row
|
||||
gets the theme's native (blue) highlight color instead of whatever a
|
||||
stylesheet might otherwise hardcode.
|
||||
"""
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
painter.save()
|
||||
if option.state & QStyle.StateFlag.State_Selected:
|
||||
painter.fillRect(option.rect, option.palette.highlight())
|
||||
painter.setPen(option.palette.highlightedText().color())
|
||||
else:
|
||||
painter.setPen(option.palette.text().color())
|
||||
painter.setFont(option.font)
|
||||
|
||||
text = index.data(Qt.ItemDataRole.DisplayRole) or ""
|
||||
rect = option.rect.adjusted(4, _ROW_MARGIN // 2, -4, -(_ROW_MARGIN // 2))
|
||||
# painter.drawText(rect, int(Qt.TextFlag.TextWordWrap), text)
|
||||
painter.drawText(
|
||||
rect,
|
||||
int(
|
||||
Qt.TextFlag.TextWordWrap
|
||||
| Qt.AlignmentFlag.AlignLeft
|
||||
| Qt.AlignmentFlag.AlignVCenter
|
||||
),
|
||||
text,
|
||||
)
|
||||
painter.restore()
|
||||
|
||||
def sizeHint(self, option, index):
|
||||
text = index.data(Qt.ItemDataRole.DisplayRole) or ""
|
||||
lines = text.count("\n") + 1
|
||||
height = option.fontMetrics.lineSpacing() * lines + _ROW_MARGIN
|
||||
width = super().sizeHint(option, index).width()
|
||||
return QSize(width, height)
|
||||
|
||||
|
||||
class MyTableView(QTableView):
|
||||
deletePressed = Signal()
|
||||
emptySpaceClicked = Signal()
|
||||
copyPressed = Signal()
|
||||
pastePressed = Signal()
|
||||
|
||||
# Column layout is fixed: 0 = status icon, 1 = text (stretches), 2 = pause-on-failure icon.
|
||||
TEXT_COLUMN = 1
|
||||
|
||||
def __init__(self, parent=None, status_col_width: int = 60, pause_col_width: int = 130):
|
||||
super().__init__(parent)
|
||||
|
||||
self._status_col_width = status_col_width
|
||||
self._pause_col_width = pause_col_width
|
||||
|
||||
self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
|
||||
self.setSelectionMode(QTableView.SelectionMode.SingleSelection)
|
||||
self.setEditTriggers(QTableView.EditTrigger.NoEditTriggers)
|
||||
self.setShowGrid(False)
|
||||
self.setWordWrap(True)
|
||||
|
||||
self.setItemDelegateForColumn(self.TEXT_COLUMN, _MultilineTextDelegate(self))
|
||||
|
||||
self.verticalHeader().hide()
|
||||
# ResizeToContents (not Fixed) so a row grows to fit however many
|
||||
# lines its text cell reports via the delegate's sizeHint above -
|
||||
# this is what lets multi-line item text show in full again.
|
||||
self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||
|
||||
# No `:selected` rule here on purpose - leaving it out lets Qt fall
|
||||
# back to the palette highlight color (blue) for selected rows,
|
||||
# matching BecLogTableView instead of a hardcoded dark gray.
|
||||
# self.setStyleSheet("""
|
||||
# QTableView::item {
|
||||
# border-bottom: 1px solid #444444;
|
||||
# padding: 4px;
|
||||
# }
|
||||
# """)
|
||||
|
||||
def configure_columns(self):
|
||||
header = self.horizontalHeader()
|
||||
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
|
||||
|
||||
header.resizeSection(0, self._status_col_width)
|
||||
header.resizeSection(2, self._pause_col_width)
|
||||
|
||||
header.setStretchLastSection(False)
|
||||
header.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.key() == Qt.Key_Delete and self.currentIndex().isValid():
|
||||
self.deletePressed.emit()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if event.matches(QKeySequence.StandardKey.Copy) and self.currentIndex().isValid():
|
||||
self.copyPressed.emit()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if event.matches(QKeySequence.StandardKey.Paste):
|
||||
self.pastePressed.emit()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if (
|
||||
event.button() == Qt.LeftButton
|
||||
and not self.indexAt(event.position().toPoint()).isValid()
|
||||
):
|
||||
self.clearSelection()
|
||||
self.setCurrentIndex(QModelIndex())
|
||||
self.emptySpaceClicked.emit()
|
||||
return
|
||||
|
||||
super().mousePressEvent(event)
|
||||
|
||||
|
||||
class MyButton(QPushButton):
|
||||
def __init__(self, text="", color="default", parent=None):
|
||||
self.color = color
|
||||
super().__init__(text, parent)
|
||||
self.apply_theme()
|
||||
|
||||
def apply_theme(self):
|
||||
if self.isEnabled():
|
||||
colors = get_accent_colors()
|
||||
color = getattr(colors, self.color).name()
|
||||
self.setStyleSheet(f"QPushButton {{ background-color: {color}; color: white; }}")
|
||||
else:
|
||||
self.setStyleSheet(
|
||||
"QPushButton {{background-color: rgb(120, 120, 120); color: white;}}"
|
||||
)
|
||||
|
||||
def setEnabled(self, enable: bool = True):
|
||||
super().setEnabled(enable)
|
||||
self.apply_theme()
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from .scheduler_plugin import SchedulerPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(SchedulerPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Plain (non-BECMessage) data model for one schedule entry and the schedule
|
||||
as a whole.
|
||||
|
||||
These are ordinary pydantic models used only client-side, for validation
|
||||
and convenience. They are never sent over Redis as their own type - only
|
||||
ever as the `.value` payload of a `bec_lib.messages.VariableMessage` (see
|
||||
`endpoints.schedule` for why).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .enums import ScheduleItemStatus
|
||||
from .guard import GuardSettings
|
||||
from .notifications import NotificationSettings
|
||||
|
||||
# Which tab of the Add/Edit dialog built `command`, and therefore how to
|
||||
# re-open that dialog pre-filled with the same values for editing. "custom"
|
||||
# covers hand-typed text (including RPC calls to other widgets), which has
|
||||
# no structured `form_state` to restore beyond the raw text itself.
|
||||
ScheduleItemKind = Literal["scan", "move", "custom"]
|
||||
|
||||
|
||||
class ScheduleItem(BaseModel):
|
||||
"""A single command in the schedule, plus its execution bookkeeping."""
|
||||
|
||||
item_id: str
|
||||
command: str
|
||||
status: ScheduleItemStatus = ScheduleItemStatus.PENDING
|
||||
|
||||
# How `command` was produced, and (for "scan"/"move") the structured
|
||||
# inputs behind it, so the Add/Edit dialog can be reopened pre-filled
|
||||
# instead of asking the user to re-type everything. `command` itself
|
||||
# remains the single source of truth actually evaluated at execution
|
||||
# time - `kind`/`form_state` only drive the UI.
|
||||
kind: ScheduleItemKind = "custom"
|
||||
form_state: dict | None = None
|
||||
pause_on_failure: bool
|
||||
|
||||
# Bookkeeping used to reconnect to a submission that is still (or was)
|
||||
# in flight on the BEC scan/device server, after this widget has been
|
||||
# closed and reopened.
|
||||
request_id: str | None = None
|
||||
scan_id: str | None = None
|
||||
scan_number: int | None = None
|
||||
|
||||
error: str | None = None
|
||||
started_at: float | None = None
|
||||
finished_at: float | None = None
|
||||
|
||||
|
||||
class Schedule(BaseModel):
|
||||
"""The full, persisted state of one schedule-widget instance."""
|
||||
|
||||
schedule_name: str
|
||||
items: list[ScheduleItem] = Field(default_factory=list)
|
||||
is_running: bool = False
|
||||
notes: str = ""
|
||||
|
||||
# Settings for two independent, optional features (see notifications.py
|
||||
# and guard.py) - persisted here alongside the schedule itself so they
|
||||
# survive a widget restart too, but their *logic* lives entirely in
|
||||
# those separate modules; this is just where their settings are stored.
|
||||
notifications: NotificationSettings = Field(default_factory=NotificationSettings)
|
||||
guard: GuardSettings = Field(default_factory=GuardSettings)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Pure bookkeeping over a list of `ScheduleItem`s - no Qt, no locking, no I/O.
|
||||
Kept separate from `schedule_widget.py` for the same reason `guard.py` and
|
||||
`notifications.py` are: it's testable on its own, and the widget should
|
||||
only be responsible for Qt/orchestration, not figuring out which item runs
|
||||
next.
|
||||
|
||||
The one invariant everything here assumes and preserves: at any moment, a
|
||||
schedule's items form a (possibly empty) prefix that is no longer PENDING
|
||||
(RUNNING - at most one, the one currently executing - COMPLETED, FAILED or
|
||||
ABORTED), followed by a suffix that is entirely PENDING. `ScheduleWidget`
|
||||
is responsible for only ever mutating the PENDING suffix (see its module
|
||||
docstring) and for calling these functions while holding its lock; nothing
|
||||
here does its own locking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from .schedule_item import ScheduleItem, ScheduleItemStatus
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def protected_prefix_length(items: list[ScheduleItem]) -> int:
|
||||
"""How many items, from the start, are no longer PENDING."""
|
||||
count = 0
|
||||
for item in items:
|
||||
if item.status != ScheduleItemStatus.PENDING:
|
||||
count += 1
|
||||
else:
|
||||
break
|
||||
return count
|
||||
|
||||
|
||||
def index_of(items: list[ScheduleItem], item_id: str | None) -> int | None:
|
||||
if item_id is None:
|
||||
return None
|
||||
for i, item in enumerate(items):
|
||||
if item.item_id == item_id:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def pick_next_runnable(
|
||||
items: list[ScheduleItem], repeat_aborted_item, first_item: int = 0
|
||||
) -> ScheduleItem | Literal["stop"] | None:
|
||||
"""
|
||||
What the execution loop should do next: always re-derived from scratch
|
||||
(never a remembered index/object) so edits made to the PENDING suffix
|
||||
between calls are picked up correctly.
|
||||
|
||||
Returns the next item to run/attach to, the string `"stop"` if an
|
||||
earlier item failed/was aborted (execution stays parked there until the
|
||||
operator intervenes), or `None` if every item is COMPLETED.
|
||||
"""
|
||||
logger.info(
|
||||
f"pick next runnable, repeat abort item is {repeat_aborted_item} and first item is {first_item}"
|
||||
)
|
||||
for idx, item in enumerate(items):
|
||||
logger.info(f"item: {item}")
|
||||
if idx < first_item:
|
||||
continue
|
||||
if item.status == ScheduleItemStatus.COMPLETED:
|
||||
continue
|
||||
if item.status == ScheduleItemStatus.ABORTED:
|
||||
if not repeat_aborted_item:
|
||||
continue
|
||||
if item.status == ScheduleItemStatus.FAILED:
|
||||
return "stop"
|
||||
return item # PENDING, RUNNING or ABORTED if repeat_aborted_item is set (reconciled as still active)
|
||||
return None
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
{'files': ['scheduler.py']}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from .scheduler import Scheduler
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='Scheduler' name='scheduler'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class SchedulerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = Scheduler(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(Scheduler.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "scheduler"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "Scheduler"
|
||||
|
||||
def toolTip(self):
|
||||
return "Scheduler"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
Whitespace-only changes.
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Universal Qt widgets
|
||||
"""
|
||||
|
||||
from functools import partial
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from qtpy.QtCore import Qt
|
||||
|
||||
# pylint: disable=E0611
|
||||
from qtpy.QtGui import QFont
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
LABEL_WIDTH = 118
|
||||
ROW_MARGINS = (4, 0, 4, 0)
|
||||
ROW_SPACING = 6
|
||||
|
||||
|
||||
class Group(QGroupBox):
|
||||
def __init__(self, label, widgets):
|
||||
super().__init__(label)
|
||||
self.layout = QVBoxLayout(self) # type: ignore
|
||||
self.layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.layout.setSpacing(4)
|
||||
for widget in widgets:
|
||||
self.layout.addWidget(widget) # type: ignore
|
||||
|
||||
|
||||
class NumberIndicator(QWidget):
|
||||
def __init__(self, label="", unit=None, highlight=False, decimals=3):
|
||||
super().__init__()
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(*ROW_MARGINS)
|
||||
layout.setSpacing(ROW_SPACING)
|
||||
self.label = QLabel(label)
|
||||
self.label.setFixedWidth(LABEL_WIDTH)
|
||||
self.label.setWordWrap(True)
|
||||
layout.addWidget(self.label)
|
||||
self.val = QLabel("-")
|
||||
self.val.setAlignment(Qt.AlignTop) # type: ignore
|
||||
layout.addWidget(self.val)
|
||||
self.unit = unit
|
||||
self.highlight = highlight
|
||||
self.decimals = decimals
|
||||
self.number = 0
|
||||
if highlight:
|
||||
font = QFont()
|
||||
font.setBold(True)
|
||||
font.setPointSize(14)
|
||||
self.label.setFont(font)
|
||||
self.val.setFont(font)
|
||||
|
||||
def value(self) -> float:
|
||||
return self.number
|
||||
|
||||
def setLabel(self, label) -> None:
|
||||
self.label.setText(label)
|
||||
|
||||
def setValue(self, number):
|
||||
self.number = number
|
||||
text = f"{number:.{int(self.decimals)}f}"
|
||||
if self.unit is not None:
|
||||
text = text + " " + self.unit
|
||||
self.val.setText(text)
|
||||
|
||||
|
||||
class InputNumberField(QWidget):
|
||||
def __init__(
|
||||
self,
|
||||
identifier="",
|
||||
label="",
|
||||
unit=None,
|
||||
prefix=None,
|
||||
init=0.0,
|
||||
decimals=1,
|
||||
single_step=0.1,
|
||||
ll=-1e6,
|
||||
hl=1e6,
|
||||
):
|
||||
super().__init__()
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(*ROW_MARGINS)
|
||||
layout.setSpacing(ROW_SPACING)
|
||||
self.identifier = identifier
|
||||
self.label = QLabel(label)
|
||||
self.label.setFixedWidth(LABEL_WIDTH)
|
||||
self.label.setWordWrap(True)
|
||||
layout.addWidget(self.label)
|
||||
self.val = QDoubleSpinBox()
|
||||
self.val.setRange(ll, hl)
|
||||
self.val.setDecimals(decimals)
|
||||
self.val.setSingleStep(single_step)
|
||||
self.val.setValue(init)
|
||||
if unit is not None:
|
||||
self.val.setSuffix(" " + unit)
|
||||
if prefix is not None:
|
||||
self.val.setPrefix(prefix + " ")
|
||||
layout.addWidget(self.val)
|
||||
self.ll = ll
|
||||
self.hl = hl
|
||||
|
||||
def set_number(self, number):
|
||||
self.val.setValue(number)
|
||||
|
||||
def set_range(self, ll, hl):
|
||||
self.val.setRange(ll, hl)
|
||||
self.ll = ll
|
||||
self.hl = hl
|
||||
|
||||
def setUnit(self, unit: str):
|
||||
self.val.setSuffix(" " + unit)
|
||||
|
||||
def get_range(self):
|
||||
return (self.ll, self.hl)
|
||||
|
||||
def has_focus(self) -> bool:
|
||||
return self.val.hasFocus()
|
||||
|
||||
def value(self) -> float:
|
||||
return self.val.value()
|
||||
|
||||
def value_changed_connect(self, func):
|
||||
"""Connect a function to the Enter/Return key press."""
|
||||
self.val.valueChanged.connect(
|
||||
partial(
|
||||
func, identifier=self.identifier, value_obj=self.val, value=lambda: self.val.value()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ComboBox(QWidget):
|
||||
def __init__(self, identifier="", label="", enums=None):
|
||||
super().__init__()
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(*ROW_MARGINS)
|
||||
layout.setSpacing(ROW_SPACING)
|
||||
self.identifier = identifier
|
||||
self.label = QLabel(label)
|
||||
self.label.setFixedWidth(LABEL_WIDTH)
|
||||
self.label.setWordWrap(True)
|
||||
layout.addWidget(self.label)
|
||||
self.value = QComboBox()
|
||||
for entry in enums or []:
|
||||
self.value.addItem(entry)
|
||||
layout.addWidget(self.value)
|
||||
|
||||
def set_current_text(self, text):
|
||||
self.value.setCurrentText(text)
|
||||
|
||||
def currentText(self) -> str:
|
||||
return self.value.currentText()
|
||||
|
||||
def has_focus(self) -> bool:
|
||||
return QApplication.focusWidget() is self.value.view()
|
||||
|
||||
def activated_connect(self, func):
|
||||
"""Connect a function to the Enter/Return key press."""
|
||||
self.value.activated.connect(
|
||||
partial(
|
||||
func,
|
||||
identifier=self.identifier,
|
||||
value_obj=self.value,
|
||||
value=lambda: self.value.currentText(),
|
||||
index=lambda: self.value.currentIndex(),
|
||||
)
|
||||
)
|
||||
|
||||
def setDisabled(self, disable):
|
||||
self.value.setDisabled(disable)
|
||||
|
||||
def get_all_texts(self) -> list[str]:
|
||||
return [self.value.itemText(i) for i in range(self.value.count())]
|
||||
|
||||
|
||||
class Button(QWidget):
|
||||
def __init__(
|
||||
self, label=None, label_button: str = "", enabled: bool = False, color: str = "default"
|
||||
):
|
||||
super().__init__()
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(*ROW_MARGINS)
|
||||
layout.setSpacing(ROW_SPACING)
|
||||
if label is not None:
|
||||
self.label = QLabel(label)
|
||||
self.label.setFixedWidth(LABEL_WIDTH)
|
||||
layout.addWidget(self.label)
|
||||
self.button = QPushButton(label_button)
|
||||
self.setEnabled(enabled)
|
||||
layout.addWidget(self.button)
|
||||
self.color = color
|
||||
|
||||
def clicked_connect(self, func):
|
||||
"""Connect a function to the button press."""
|
||||
self.button.clicked.connect(func)
|
||||
|
||||
def setEnabled(self, enable: bool = True):
|
||||
self.button.setEnabled(enable)
|
||||
self.apply_theme()
|
||||
|
||||
def setText(self, text):
|
||||
self.button.setText(text)
|
||||
|
||||
def setColor(self, color: str):
|
||||
self.color = color
|
||||
self.apply_theme()
|
||||
|
||||
def apply_theme(self):
|
||||
if self.button.isEnabled():
|
||||
colors = get_accent_colors()
|
||||
color = getattr(colors, self.color).name()
|
||||
self.button.setStyleSheet(f"QPushButton {{ background-color: {color}; color: white; }}")
|
||||
else:
|
||||
self.button.setStyleSheet(
|
||||
"QPushButton {{background-color: rgb(120, 120, 120); color: white;}}"
|
||||
)
|
||||
|
||||
|
||||
class TextIndicator(QWidget):
|
||||
def __init__(self, label):
|
||||
super().__init__()
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(*ROW_MARGINS)
|
||||
layout.setSpacing(ROW_SPACING)
|
||||
self.label = QLabel(label)
|
||||
self.label.setFixedWidth(LABEL_WIDTH)
|
||||
self.label.setWordWrap(True)
|
||||
layout.addWidget(self.label)
|
||||
self.text = QLabel("-")
|
||||
self.text.setAlignment(Qt.AlignTop) # type: ignore
|
||||
layout.addWidget(self.text)
|
||||
|
||||
def setLabel(self, label) -> None:
|
||||
self.label.setText(label)
|
||||
|
||||
def setText(self, text):
|
||||
self.text.setText(text)
|
||||
|
||||
def setColor(self, color: str):
|
||||
self.text.setStyleSheet(f"QLabel {{color:{color}}}")
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from .temperature_controller_plugin import TemperatureControllerPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(TemperatureControllerPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Temperature controller widget"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
# pylint: disable=E0611
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget
|
||||
|
||||
from ....devices.univ_temp_cont.univ_temp_cont_enums import (
|
||||
RAMP_RATE_MAP,
|
||||
UnivTempContHeaterTcFault,
|
||||
UnivTempContOpMode,
|
||||
UnivTempContPower,
|
||||
UnivTempContRampRateUnit,
|
||||
UnivTempContSensorBreak,
|
||||
)
|
||||
from .qt_widgets import ComboBox, Group, InputNumberField, NumberIndicator, TextIndicator
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
DEFAULT_WINDOW_S = 600
|
||||
REFRESH_MS = 200
|
||||
PLOT_SIGNALS = ["temperature", "setpoint", "output_power"]
|
||||
READ_CONFIG_SIGNAL_MS = 1000
|
||||
|
||||
|
||||
class TemperatureControllerError(Exception):
|
||||
"""Temperature Controller specific error"""
|
||||
|
||||
|
||||
class TemperatureController(BECWidget, QWidget):
|
||||
"""Waveform with predefined curves and one status label."""
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "mode_heat"
|
||||
|
||||
def __init__(self, *arg, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, *arg, **kwargs)
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
if "univ_temp_cont" not in self.dev:
|
||||
raise TemperatureControllerError(
|
||||
"Universal Temperature Controller is not in the config, abort."
|
||||
)
|
||||
|
||||
self._window_s = DEFAULT_WINDOW_S
|
||||
|
||||
root_layout = QHBoxLayout(self)
|
||||
|
||||
control_layout = QVBoxLayout()
|
||||
control_layout.setContentsMargins(4, 4, 4, 4)
|
||||
control_layout.setSpacing(4)
|
||||
|
||||
self.sensor_break = TextIndicator("Thermocouple")
|
||||
self.operating_mode = TextIndicator("Operating mode")
|
||||
self.output_power = NumberIndicator("Output Power", "%", decimals=0)
|
||||
self.temperature = NumberIndicator("Temperature", "°C", decimals=0)
|
||||
self.heater_temperature = NumberIndicator("Heater temperature", "°C", decimals=0)
|
||||
self.heater_sensor_break = TextIndicator("Heater Thermocouple")
|
||||
|
||||
status_group = Group(
|
||||
"Status",
|
||||
[
|
||||
self.sensor_break,
|
||||
self.operating_mode,
|
||||
self.output_power,
|
||||
self.temperature,
|
||||
self.heater_temperature,
|
||||
self.heater_sensor_break,
|
||||
],
|
||||
)
|
||||
|
||||
self.power_mode = ComboBox("power_mode", "Power mode", UnivTempContPower)
|
||||
self.target_setpoint = InputNumberField(
|
||||
"target_setpoint", "Target setpoint", "°C", decimals=0, single_step=1, ll=0, hl=850
|
||||
)
|
||||
self.setpoint = NumberIndicator("Setpoint", "°C", highlight=True, decimals=0)
|
||||
self.ramp_rate = InputNumberField(
|
||||
"ramp-rate", "Ramp rate", decimals=0, single_step=1, ll=0, hl=1e5
|
||||
)
|
||||
self.ramp_rate_unit = ComboBox("ramp_rate_unit", "Ramp rate unit", UnivTempContRampRateUnit)
|
||||
|
||||
control_group = Group(
|
||||
"Control",
|
||||
[
|
||||
self.power_mode,
|
||||
self.target_setpoint,
|
||||
self.setpoint,
|
||||
self.ramp_rate,
|
||||
self.ramp_rate_unit,
|
||||
],
|
||||
)
|
||||
|
||||
control_layout.addWidget(status_group)
|
||||
control_layout.addWidget(control_group)
|
||||
|
||||
root_layout.addLayout(control_layout)
|
||||
|
||||
self.waveform = Waveform(parent=self, client=self.client, popups=False)
|
||||
root_layout.addWidget(self.waveform, stretch=1)
|
||||
|
||||
self.waveform.set(title="Title", x_label="X-Label")
|
||||
|
||||
self._buffers: dict[str, deque] = {}
|
||||
self._curves = {}
|
||||
for signal in PLOT_SIGNALS:
|
||||
self._buffers[signal] = deque() # (timestamp, value), trimmed by time
|
||||
self._curves[signal] = self.waveform.plot(
|
||||
x=[], y=[], label=self.dev.univ_temp_cont.signal.full_name
|
||||
)
|
||||
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self._on_readback, MessageEndpoints.device_readback(self.dev.univ_temp_cont)
|
||||
)
|
||||
self._refresh_timer = QTimer(self)
|
||||
self._refresh_timer.timeout.connect(self._redraw)
|
||||
self._refresh_timer.start(REFRESH_MS)
|
||||
|
||||
self._config_signal_timer = QTimer(self)
|
||||
self._config_signal_timer.timeout.connect(self._config_signal_read)
|
||||
self._config_signal_timer.start(READ_CONFIG_SIGNAL_MS)
|
||||
|
||||
self.power_mode.activated_connect(self._on_value_changed)
|
||||
self.target_setpoint.value_changed_connect(self._on_value_changed)
|
||||
self.ramp_rate.value_changed_connect(self._on_value_changed)
|
||||
self.ramp_rate_unit.activated_connect(self._on_value_changed)
|
||||
|
||||
@SafeSlot()
|
||||
def _on_value_changed(self, identifier, _, value, **kwargs):
|
||||
logger.info(f"Got identifier {identifier} with value {value} and kwargs {kwargs}")
|
||||
if "index" in kwargs.keys():
|
||||
value = kwargs["index"]
|
||||
self.dev.univ_temp_cont[identifier].put(value)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def _on_readback(self, msg: dict, *_):
|
||||
signals = msg.get("signals", {})
|
||||
for signal, buf in self._buffers.items():
|
||||
entry = signals.get(signal)
|
||||
if entry is None or entry.get("value") is None:
|
||||
continue
|
||||
buf.append((entry["timestamp"], entry["value"]))
|
||||
value = signals["temperature"]["value"]
|
||||
self.temperature.setValue(value)
|
||||
value = signals["heater_temperature"]["value"]
|
||||
self.heater_temperature.setValue(value)
|
||||
value = signals["output_power"]["value"]
|
||||
self.output_power.setValue(value)
|
||||
value = signals["sensor_break"]["value"]
|
||||
match value:
|
||||
case UnivTempContSensorBreak.GOOD:
|
||||
self.sensor_break.setText("OK")
|
||||
self.sensor_break.setColor(get_accent_colors().success.name())
|
||||
case UnivTempContSensorBreak.SENSOR_BREAK:
|
||||
self.sensor_break.setText("FAULT")
|
||||
self.sensor_break.setColor(get_accent_colors().emergency.name())
|
||||
case _:
|
||||
raise TemperatureControllerError(f"Not implemented sensor_break value {value}")
|
||||
value = signals["heater_tc_fault"]["value"]
|
||||
match value:
|
||||
case UnivTempContHeaterTcFault.NO_FAULT:
|
||||
self.heater_sensor_break.setText("OK")
|
||||
self.heater_sensor_break.setColor(get_accent_colors().success.name())
|
||||
case UnivTempContHeaterTcFault.TC_OPEN_FAULT:
|
||||
self.heater_sensor_break.setText("OPEN")
|
||||
self.heater_sensor_break.setColor(get_accent_colors().warning.name())
|
||||
case (
|
||||
UnivTempContHeaterTcFault.OVER_UNDERVOLTAGE_FAULT
|
||||
| UnivTempContHeaterTcFault.TC_LOW_FAULT
|
||||
| UnivTempContHeaterTcFault.TC_HIGH_FAULT
|
||||
| UnivTempContHeaterTcFault.COLD_JUNC_LOW_FAULT
|
||||
| UnivTempContHeaterTcFault.COLD_JUNC_HIGH_FAULT
|
||||
| UnivTempContHeaterTcFault.TC_RANGE_FAULT
|
||||
| UnivTempContHeaterTcFault.COLD_JUNC_RNG_FAULT
|
||||
):
|
||||
self.heater_sensor_break.setText("FAULT")
|
||||
self.heater_sensor_break.setColor(get_accent_colors().emergency.name())
|
||||
case _:
|
||||
raise TemperatureControllerError(
|
||||
f"Not implemented heater_sensor_break value {value}"
|
||||
)
|
||||
value = signals["op_mode"]["value"]
|
||||
match value:
|
||||
case UnivTempContOpMode.AUTO:
|
||||
self.operating_mode.setText("AUTO")
|
||||
self.operating_mode.setColor(get_accent_colors().success.name())
|
||||
case UnivTempContOpMode.MANUAL:
|
||||
self.operating_mode.setText("MANUAL")
|
||||
self.operating_mode.setColor(get_accent_colors().warning.name())
|
||||
case UnivTempContOpMode.STANDBY:
|
||||
self.operating_mode.setText("STANDBY")
|
||||
self.operating_mode.setColor(get_accent_colors().default.name())
|
||||
case _:
|
||||
raise TemperatureControllerError(f"Not implemented op_mode value {value}")
|
||||
value = signals["power"]["value"]
|
||||
self.power_mode.set_current_text(value)
|
||||
value = signals["target_setpoint"]["value"]
|
||||
self.target_setpoint.set_number(value)
|
||||
value = signals["setpoint"]["value"]
|
||||
self.setpoint.setValue(value)
|
||||
value = signals["ramp_rate"]["value"]
|
||||
self.ramp_rate.set_number(value)
|
||||
|
||||
def _config_signal_read(self):
|
||||
value = self.dev.univ_temp_cont.ramp_rate_unit.read()
|
||||
if value is None:
|
||||
raise TemperatureControllerError("Reading ramp rate unit returned None!")
|
||||
self.ramp_rate_unit.set_current_text(value)
|
||||
self.ramp_rate.setUnit(
|
||||
list(RAMP_RATE_MAP.keys())[list(RAMP_RATE_MAP.values()).index(value)]
|
||||
)
|
||||
|
||||
def _redraw(self):
|
||||
now = time.time()
|
||||
cutoff = now - self._window_s
|
||||
for key, buf in self._buffers.items():
|
||||
while buf and buf[0][0] < cutoff:
|
||||
buf.popleft()
|
||||
if not buf:
|
||||
continue
|
||||
t, v = zip(*buf)
|
||||
self._curves[key].set_data(np.asarray(t) - now, np.asarray(v))
|
||||
|
||||
def cleanup(self):
|
||||
self._refresh_timer.stop()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
dispatcher = BECDispatcher(gui_id="restart_server")
|
||||
win = TemperatureController()
|
||||
win.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['temperature_controller.py']}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from .temperature_controller import TemperatureController
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='TemperatureController' name='temperature_controller'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class TemperatureControllerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = TemperatureController(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(TemperatureController.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "temperature_controller"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "TemperatureController"
|
||||
|
||||
def toolTip(self):
|
||||
return "TemperatureController"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -1,26 +1,41 @@
|
||||
|
||||
#######################################
|
||||
## Experimental Hutch Photon Shutter ##
|
||||
#######################################
|
||||
|
||||
eh_sh:
|
||||
readoutPriority: baseline
|
||||
description: Experimental Hutch Photon Shutter
|
||||
deviceClass: debye_bec.devices.eh_shutter.EHPhotonShutter
|
||||
deviceConfig:
|
||||
prefix: "X01DA-"
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
|
||||
###################################
|
||||
## Optical Table ##
|
||||
###################################
|
||||
|
||||
ot_tryu:
|
||||
readoutPriority: baseline
|
||||
description: Optical Table Y-Translation Upstream
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-ES-OT:TRYU
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# ot_tryu:
|
||||
# readoutPriority: baseline
|
||||
# description: Optical Table Y-Translation Upstream
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-ES-OT:TRYU
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
ot_tryd:
|
||||
readoutPriority: baseline
|
||||
description: Optical Table Y-Translation Downstream
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-ES-OT:TRYD
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# ot_tryd:
|
||||
# readoutPriority: baseline
|
||||
# description: Optical Table Y-Translation Downstream
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-ES-OT:TRYD
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
ot_es1_trz:
|
||||
readoutPriority: baseline
|
||||
@@ -94,45 +109,45 @@ es0filter:
|
||||
## Slits ES0 ##
|
||||
###################################
|
||||
|
||||
es0sl_trxr:
|
||||
readoutPriority: baseline
|
||||
description: End Station slits X-translation Ring-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-ES0-SL:TRXR
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# es0sl_trxr:
|
||||
# readoutPriority: baseline
|
||||
# description: End Station slits X-translation Ring-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-ES0-SL:TRXR
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
es0sl_trxw:
|
||||
readoutPriority: baseline
|
||||
description: End Station slits X-translation Wall-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-ES0-SL:TRXW
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# es0sl_trxw:
|
||||
# readoutPriority: baseline
|
||||
# description: End Station slits X-translation Wall-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-ES0-SL:TRXW
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
es0sl_tryb:
|
||||
readoutPriority: baseline
|
||||
description: End Station slits Y-translation Bottom-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-ES0-SL:TRYB
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# es0sl_tryb:
|
||||
# readoutPriority: baseline
|
||||
# description: End Station slits Y-translation Bottom-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-ES0-SL:TRYB
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
es0sl_tryt:
|
||||
readoutPriority: baseline
|
||||
description: End Station slits X-translation Top-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-ES0-SL:TRYT
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# es0sl_tryt:
|
||||
# readoutPriority: baseline
|
||||
# description: End Station slits X-translation Top-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-ES0-SL:TRYT
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
es0sl_center:
|
||||
readoutPriority: baseline
|
||||
@@ -266,15 +281,15 @@ es1bm1_trx:
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
|
||||
es1det2_trx:
|
||||
readoutPriority: baseline
|
||||
description: End Station SDD 2 X-translation
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-ES1-DET2:TRX
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# es1det2_trx:
|
||||
# readoutPriority: baseline
|
||||
# description: End Station SDD 2 X-translation
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-ES1-DET2:TRX
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
###################################
|
||||
## IC1 + IC2 Manipulator ##
|
||||
|
||||
@@ -17,45 +17,45 @@ abs:
|
||||
## Frontend Slits ##
|
||||
###################################
|
||||
|
||||
sldi_trxr:
|
||||
readoutPriority: baseline
|
||||
description: Front-end slit diaphragm X-translation Ring-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-FE-SLDI:TRXR
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# sldi_trxr:
|
||||
# readoutPriority: baseline
|
||||
# description: Front-end slit diaphragm X-translation Ring-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-FE-SLDI:TRXR
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
sldi_trxw:
|
||||
readoutPriority: baseline
|
||||
description: Front-end slit diaphragm X-translation Wall-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-FE-SLDI:TRXW
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# sldi_trxw:
|
||||
# readoutPriority: baseline
|
||||
# description: Front-end slit diaphragm X-translation Wall-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-FE-SLDI:TRXW
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
sldi_tryb:
|
||||
readoutPriority: baseline
|
||||
description: Front-end slit diaphragm Y-translation Bottom-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-FE-SLDI:TRYB
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# sldi_tryb:
|
||||
# readoutPriority: baseline
|
||||
# description: Front-end slit diaphragm Y-translation Bottom-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-FE-SLDI:TRYB
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
sldi_tryt:
|
||||
readoutPriority: baseline
|
||||
description: Front-end slit diaphragm X-translation Top-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-FE-SLDI:TRYT
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# sldi_tryt:
|
||||
# readoutPriority: baseline
|
||||
# description: Front-end slit diaphragm X-translation Top-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-FE-SLDI:TRYT
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
sldi_centerx:
|
||||
readoutPriority: baseline
|
||||
@@ -101,55 +101,55 @@ sldi_gapy:
|
||||
## Collimating Mirror ##
|
||||
###################################
|
||||
|
||||
cm_trxu:
|
||||
readoutPriority: baseline
|
||||
description: Collimating Mirror X-translation upstream
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-FE-CM:TRXU
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# cm_trxu:
|
||||
# readoutPriority: baseline
|
||||
# description: Collimating Mirror X-translation upstream
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-FE-CM:TRXU
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
cm_trxd:
|
||||
readoutPriority: baseline
|
||||
description: Collimating Mirror X-translation downstream
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-FE-CM:TRXD
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# cm_trxd:
|
||||
# readoutPriority: baseline
|
||||
# description: Collimating Mirror X-translation downstream
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-FE-CM:TRXD
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
cm_tryu:
|
||||
readoutPriority: baseline
|
||||
description: Collimating Mirror Y-translation upstream
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-FE-CM:TRYU
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# cm_tryu:
|
||||
# readoutPriority: baseline
|
||||
# description: Collimating Mirror Y-translation upstream
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-FE-CM:TRYU
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
cm_trydr:
|
||||
readoutPriority: baseline
|
||||
description: Collimating Mirror Y-translation downstream ring
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-FE-CM:TRYDR
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# cm_trydr:
|
||||
# readoutPriority: baseline
|
||||
# description: Collimating Mirror Y-translation downstream ring
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-FE-CM:TRYDR
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
cm_trydw:
|
||||
readoutPriority: baseline
|
||||
description: Collimating Mirror Y-translation downstream wall
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-FE-CM:TRYDW
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# cm_trydw:
|
||||
# readoutPriority: baseline
|
||||
# description: Collimating Mirror Y-translation downstream wall
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-FE-CM:TRYDW
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
cm_bnd:
|
||||
readoutPriority: baseline
|
||||
@@ -240,4 +240,4 @@ cm_xstripe:
|
||||
prefix: X01DA-FE-CM:XSTRIPE
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
softwareTrigger: false
|
||||
@@ -15,4 +15,18 @@ curr:
|
||||
onFailure: buffer
|
||||
enabled: true
|
||||
readOnly: true
|
||||
softwareTrigger: false
|
||||
|
||||
bl_status:
|
||||
readoutPriority: baseline
|
||||
description: BL status for machine
|
||||
deviceClass: ophyd.EpicsSignal
|
||||
deviceConfig:
|
||||
auto_monitor: false
|
||||
read_pv: AGEOP-BL:STATUS-X01DA
|
||||
deviceTags:
|
||||
- machine
|
||||
onFailure: buffer
|
||||
enabled: true
|
||||
readOnly: false
|
||||
softwareTrigger: false
|
||||
@@ -0,0 +1,14 @@
|
||||
######################################
|
||||
## Universal Temperature Controller ##
|
||||
######################################
|
||||
|
||||
univ_temp_cont:
|
||||
readoutPriority: monitored
|
||||
description: Universal Temperature Controller
|
||||
deviceClass: debye_bec.devices.univ_temp_cont.univ_temp_cont.UnivTempCont
|
||||
deviceConfig:
|
||||
auto_monitor: true
|
||||
prefix: "X01DA-ES-CRS:"
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
@@ -1,4 +1,18 @@
|
||||
|
||||
###################################
|
||||
## Optics Photon Shutter ##
|
||||
###################################
|
||||
|
||||
op_sh:
|
||||
readoutPriority: baseline
|
||||
description: Optics Hutch Photon Shutter
|
||||
deviceClass: debye_bec.devices.op_shutter.OPPhotonShutter
|
||||
deviceConfig:
|
||||
prefix: "X01DA-"
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
|
||||
###################################
|
||||
## Monochromator ##
|
||||
###################################
|
||||
@@ -37,57 +51,57 @@ mo1_roty:
|
||||
## Optics Slits + Beam Monitor 1 ##
|
||||
###################################
|
||||
|
||||
sl1_trxr:
|
||||
readoutPriority: baseline
|
||||
description: Optics slits 1 X-translation Ring-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-OP-SL1:TRXR
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
deviceTags:
|
||||
- optics
|
||||
- slits
|
||||
# sl1_trxr:
|
||||
# readoutPriority: baseline
|
||||
# description: Optics slits 1 X-translation Ring-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-OP-SL1:TRXR
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
# deviceTags:
|
||||
# - optics
|
||||
# - slits
|
||||
|
||||
sl1_trxw:
|
||||
readoutPriority: baseline
|
||||
description: Optics slits 1 X-translation Wall-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-OP-SL1:TRXW
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
deviceTags:
|
||||
- optics
|
||||
- slits
|
||||
# sl1_trxw:
|
||||
# readoutPriority: baseline
|
||||
# description: Optics slits 1 X-translation Wall-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-OP-SL1:TRXW
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
# deviceTags:
|
||||
# - optics
|
||||
# - slits
|
||||
|
||||
sl1_tryb:
|
||||
readoutPriority: baseline
|
||||
description: Optics slits 1 Y-translation Bottom-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-OP-SL1:TRYB
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
deviceTags:
|
||||
- optics
|
||||
- slits
|
||||
# sl1_tryb:
|
||||
# readoutPriority: baseline
|
||||
# description: Optics slits 1 Y-translation Bottom-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-OP-SL1:TRYB
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
# deviceTags:
|
||||
# - optics
|
||||
# - slits
|
||||
|
||||
sl1_tryt:
|
||||
readoutPriority: baseline
|
||||
description: Optics slits 1 X-translation Top-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-OP-SL1:TRYT
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
deviceTags:
|
||||
- optics
|
||||
- slits
|
||||
# sl1_tryt:
|
||||
# readoutPriority: baseline
|
||||
# description: Optics slits 1 X-translation Top-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-OP-SL1:TRYT
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
# deviceTags:
|
||||
# - optics
|
||||
# - slits
|
||||
|
||||
bm1_try:
|
||||
readoutPriority: baseline
|
||||
@@ -158,55 +172,55 @@ sl1_gapy:
|
||||
## Focusing Mirror ##
|
||||
###################################
|
||||
|
||||
fm_trxu:
|
||||
readoutPriority: baseline
|
||||
description: Focusing Mirror X-translation upstream
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-OP-FM:TRXU
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# fm_trxu:
|
||||
# readoutPriority: baseline
|
||||
# description: Focusing Mirror X-translation upstream
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-OP-FM:TRXU
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
fm_trxd:
|
||||
readoutPriority: baseline
|
||||
description: Focusing Mirror X-translation downstream
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-OP-FM:TRXD
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# fm_trxd:
|
||||
# readoutPriority: baseline
|
||||
# description: Focusing Mirror X-translation downstream
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-OP-FM:TRXD
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
fm_tryd:
|
||||
readoutPriority: baseline
|
||||
description: Focusing Mirror Y-translation downstream
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-OP-FM:TRYD
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# fm_tryd:
|
||||
# readoutPriority: baseline
|
||||
# description: Focusing Mirror Y-translation downstream
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-OP-FM:TRYD
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
fm_tryur:
|
||||
readoutPriority: baseline
|
||||
description: Focusing Mirror Y-translation upstream ring
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-OP-FM:TRYUR
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# fm_tryur:
|
||||
# readoutPriority: baseline
|
||||
# description: Focusing Mirror Y-translation upstream ring
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-OP-FM:TRYUR
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
fm_tryuw:
|
||||
readoutPriority: baseline
|
||||
description: Focusing Mirror Y-translation upstream wall
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-OP-FM:TRYUW
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
# fm_tryuw:
|
||||
# readoutPriority: baseline
|
||||
# description: Focusing Mirror Y-translation upstream wall
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-OP-FM:TRYUW
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
|
||||
fm_bnd:
|
||||
readoutPriority: baseline
|
||||
@@ -293,57 +307,57 @@ fm_ztcp:
|
||||
## Optics Slits + Beam Monitor 2 ##
|
||||
###################################
|
||||
|
||||
sl2_trxr:
|
||||
readoutPriority: baseline
|
||||
description: Optics slits 2 X-translation Ring-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-OP-SL2:TRXR
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
deviceTags:
|
||||
- optics
|
||||
- slits
|
||||
# sl2_trxr:
|
||||
# readoutPriority: baseline
|
||||
# description: Optics slits 2 X-translation Ring-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-OP-SL2:TRXR
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
# deviceTags:
|
||||
# - optics
|
||||
# - slits
|
||||
|
||||
sl2_trxw:
|
||||
readoutPriority: baseline
|
||||
description: Optics slits 2 X-translation Wall-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-OP-SL2:TRXW
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
deviceTags:
|
||||
- optics
|
||||
- slits
|
||||
# sl2_trxw:
|
||||
# readoutPriority: baseline
|
||||
# description: Optics slits 2 X-translation Wall-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-OP-SL2:TRXW
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
# deviceTags:
|
||||
# - optics
|
||||
# - slits
|
||||
|
||||
sl2_tryb:
|
||||
readoutPriority: baseline
|
||||
description: Optics slits 2 Y-translation Bottom-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-OP-SL2:TRYB
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
deviceTags:
|
||||
- optics
|
||||
- slits
|
||||
# sl2_tryb:
|
||||
# readoutPriority: baseline
|
||||
# description: Optics slits 2 Y-translation Bottom-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-OP-SL2:TRYB
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
# deviceTags:
|
||||
# - optics
|
||||
# - slits
|
||||
|
||||
sl2_tryt:
|
||||
readoutPriority: baseline
|
||||
description: Optics slits 2 X-translation Top-edge
|
||||
deviceClass: ophyd_devices.EpicsMotorEC
|
||||
deviceConfig:
|
||||
prefix: X01DA-OP-SL2:TRYT
|
||||
onFailure: retry
|
||||
enabled: true
|
||||
softwareTrigger: false
|
||||
deviceTags:
|
||||
- optics
|
||||
- slits
|
||||
# sl2_tryt:
|
||||
# readoutPriority: baseline
|
||||
# description: Optics slits 2 X-translation Top-edge
|
||||
# deviceClass: ophyd_devices.EpicsMotorEC
|
||||
# deviceConfig:
|
||||
# prefix: X01DA-OP-SL2:TRYT
|
||||
# onFailure: retry
|
||||
# enabled: true
|
||||
# softwareTrigger: false
|
||||
# deviceTags:
|
||||
# - optics
|
||||
# - slits
|
||||
|
||||
bm2_try:
|
||||
readoutPriority: baseline
|
||||
@@ -408,4 +422,4 @@ sl2_gapy:
|
||||
softwareTrigger: false
|
||||
deviceTags:
|
||||
- optics
|
||||
- slits
|
||||
- slits
|
||||
@@ -7,11 +7,16 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from ophyd import Component as Cpt
|
||||
from ophyd import EpicsSignal, EpicsSignalRO
|
||||
from ophyd_devices import CompareStatus, DeviceStatus
|
||||
from ophyd_devices import CompareStatus
|
||||
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
|
||||
|
||||
from .utils.bl_status_enum import BlStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.devicemanager import ScanInfo
|
||||
from bec_server.device_server.device_server import DeviceManagerDS
|
||||
|
||||
TIMEOUT_FOR_PV = 5
|
||||
|
||||
|
||||
class AbsorberError(Exception):
|
||||
@@ -38,6 +43,13 @@ class STATUS(int, enum.Enum):
|
||||
UNDEFINED = 14
|
||||
|
||||
|
||||
class BL_ENABLE(int, enum.Enum):
|
||||
"""Beamline enable"""
|
||||
|
||||
DISABLE = 0
|
||||
ENABLE = 1
|
||||
|
||||
|
||||
class Absorber(PSIDeviceBase):
|
||||
"""Class for the Frontend Absorber"""
|
||||
|
||||
@@ -55,30 +67,56 @@ class Absorber(PSIDeviceBase):
|
||||
string=True,
|
||||
doc="Absorber Status",
|
||||
)
|
||||
close4bl = Cpt(EpicsSignal, suffix="CLOSE4BL", kind="config", doc="Beamline enable")
|
||||
|
||||
def __init__(self, *, name: str, prefix: str = "", scan_info: ScanInfo | None = None, **kwargs):
|
||||
super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs)
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
prefix: str = "",
|
||||
scan_info: ScanInfo | None = None,
|
||||
device_manager: DeviceManagerDS | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs
|
||||
)
|
||||
self.device_manager = device_manager
|
||||
|
||||
self.timeout_for_move = 10
|
||||
# Wait for connection on all components, ensure IOC is connected
|
||||
self.wait_for_connection(all_signals=True, timeout=5)
|
||||
|
||||
def open(self) -> DeviceStatus | None:
|
||||
"""Open the Absorber"""
|
||||
if self.status.get() == STATUS.CLOSED:
|
||||
self.request.put(1)
|
||||
status_open = CompareStatus(self.status, STATUS.OPEN, timeout=self.timeout_for_move)
|
||||
status = status_open
|
||||
return status
|
||||
else:
|
||||
return None
|
||||
def open(self, force: bool = False, wait: bool = True) -> CompareStatus | None:
|
||||
"""Open the Absorber
|
||||
|
||||
def close(self) -> DeviceStatus | None:
|
||||
Args:
|
||||
force(bool): If needed, set bl status to enable and bl enable to ENABLE, defaults to False
|
||||
|
||||
"""
|
||||
if self.status.get() == STATUS.CLOSED:
|
||||
if force:
|
||||
if self.device_manager.devices.bl_status.get() == BlStatus.OFFLINE:
|
||||
status = self.device_manager.devices.bl_status.put(BlStatus.ATTENDED)
|
||||
status.wait(timeout=TIMEOUT_FOR_PV)
|
||||
if self.close4bl.get() == BL_ENABLE.DISABLE:
|
||||
status = self.close4bl.set(BL_ENABLE.ENABLE)
|
||||
status.wait(timeout=TIMEOUT_FOR_PV)
|
||||
self.request.put(1)
|
||||
status = CompareStatus(self.status, STATUS.OPEN, timeout=self.timeout_for_move)
|
||||
if wait:
|
||||
status.wait(timeout=self.timeout_for_move)
|
||||
return None
|
||||
return status
|
||||
return None
|
||||
|
||||
def close(self, wait: bool = True) -> CompareStatus | None:
|
||||
"""Close the Absorber"""
|
||||
if self.status.get() == STATUS.OPEN:
|
||||
self.request.put(1)
|
||||
status_close = CompareStatus(self.status, STATUS.CLOSED, timeout=self.timeout_for_move)
|
||||
status = status_close
|
||||
status = CompareStatus(self.status, STATUS.CLOSED, timeout=self.timeout_for_move)
|
||||
if wait:
|
||||
status.wait(timeout=self.timeout_for_move)
|
||||
return None
|
||||
return status
|
||||
else:
|
||||
return None
|
||||
return None
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Experimental Hutch Photon Shutter"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ophyd import Component as Cpt
|
||||
from ophyd import EpicsSignal, EpicsSignalRO
|
||||
from ophyd_devices import CompareStatus
|
||||
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.devicemanager import ScanInfo
|
||||
|
||||
TIMEOUT_FOR_PV = 5
|
||||
|
||||
|
||||
class STATUS(int, enum.Enum):
|
||||
"""Shutter States"""
|
||||
|
||||
NOT_CLOSED = 0
|
||||
CLOSED = 1
|
||||
|
||||
|
||||
class BL_ENABLE(int, enum.Enum):
|
||||
"""Beamline enable"""
|
||||
|
||||
DISABLE = 0
|
||||
ENABLE = 1
|
||||
|
||||
|
||||
class EHPhotonShutter(PSIDeviceBase):
|
||||
"""Class for the Experimental Hutch Photon Shutter"""
|
||||
|
||||
USER_ACCESS = ["open", "close"]
|
||||
|
||||
request_open = Cpt(
|
||||
EpicsSignal, suffix="EH1-PSYS:SH-A-OPEN-SET", kind="config", doc="Open Shutter"
|
||||
)
|
||||
request_close = Cpt(
|
||||
EpicsSignal, suffix="EH1-PSYS:SH-A-CLOSE-SET", kind="config", doc="Close Shutter"
|
||||
)
|
||||
status = Cpt(
|
||||
EpicsSignalRO,
|
||||
suffix="EH1-PSYS:SH-A-CLOSE",
|
||||
kind="normal",
|
||||
auto_monitor=True,
|
||||
doc="Shutter Status",
|
||||
)
|
||||
status_string = Cpt(
|
||||
EpicsSignalRO,
|
||||
suffix="EH1-PSYS:SH-A-CLOSE",
|
||||
kind="normal",
|
||||
auto_monitor=True,
|
||||
string=True,
|
||||
doc="Shutter Status",
|
||||
)
|
||||
|
||||
def __init__(self, *, name: str, prefix: str = "", scan_info: ScanInfo | None = None, **kwargs):
|
||||
super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs)
|
||||
|
||||
self.timeout_for_move = 10
|
||||
# Wait for connection on all components, ensure IOC is connected
|
||||
self.wait_for_connection(all_signals=True, timeout=5)
|
||||
|
||||
def open(self, wait: bool = True) -> CompareStatus | None:
|
||||
"""Open the Shutter"""
|
||||
if self.status.get() == STATUS.CLOSED:
|
||||
self.request_open.put(1)
|
||||
status = CompareStatus(self.status, STATUS.NOT_CLOSED, timeout=self.timeout_for_move)
|
||||
if wait:
|
||||
status.wait(timeout=self.timeout_for_move)
|
||||
return None
|
||||
return status
|
||||
return None
|
||||
|
||||
def close(self, wait: bool = True) -> CompareStatus | None:
|
||||
"""Close the Shutter"""
|
||||
if self.status.get() == STATUS.NOT_CLOSED:
|
||||
self.request_close.put(1)
|
||||
status = CompareStatus(self.status, STATUS.CLOSED, timeout=self.timeout_for_move)
|
||||
if wait:
|
||||
status.wait(timeout=self.timeout_for_move)
|
||||
return None
|
||||
return status
|
||||
return None
|
||||
@@ -14,6 +14,7 @@ from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
|
||||
from typeguard import typechecked
|
||||
|
||||
from debye_bec.devices.ionization_chambers.ionization_chamber_enums import (
|
||||
GAIN_MAP,
|
||||
AmplifierEnable,
|
||||
AmplifierFilter,
|
||||
AmplifierGain,
|
||||
@@ -120,12 +121,16 @@ class IonizationChamber0(PSIDeviceBase):
|
||||
self.timeout_for_pvwait = 2.5
|
||||
super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs)
|
||||
|
||||
@typechecked
|
||||
def set_gain(self, gain: Literal["1e6", "1e7", "5e7", "1e8", "1e9"]) -> None:
|
||||
def set_gain(self, gain: float) -> None:
|
||||
"""Configure the gain setting of the specified channel
|
||||
|
||||
Args:
|
||||
gain (Literal['1e6', '1e7', '5e7', '1e8', '1e9']) : Desired gain
|
||||
gain(float): Amplifier gain to set. Accepts either a float/int or a string,
|
||||
e.g. 1e6 or "1e6". Must be one of: 1e6, 1e7, 5e7, 1e8, 1e9.
|
||||
|
||||
Raises:
|
||||
ValueError: If `gain` cannot be converted to a float, or does not
|
||||
match one of the supported gain values.
|
||||
"""
|
||||
|
||||
if self.amp.cOnOff.get() == AmplifierEnable.OFF:
|
||||
@@ -134,17 +139,21 @@ class IonizationChamber0(PSIDeviceBase):
|
||||
self.amp.cOnOff.put(AmplifierEnable.ON)
|
||||
status.wait(self.timeout_for_pvwait)
|
||||
|
||||
match gain:
|
||||
case "1e6":
|
||||
self.amp.cGain_ENUM.put(AmplifierGain.G1E6)
|
||||
case "1e7":
|
||||
self.amp.cGain_ENUM.put(AmplifierGain.G1E7)
|
||||
case "5e7":
|
||||
self.amp.cGain_ENUM.put(AmplifierGain.G5E7)
|
||||
case "1e8":
|
||||
self.amp.cGain_ENUM.put(AmplifierGain.G1E8)
|
||||
case "1e9":
|
||||
self.amp.cGain_ENUM.put(AmplifierGain.G1E9)
|
||||
try:
|
||||
key = float(gain)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Invalid gain: {gain!r}") from exc
|
||||
|
||||
try:
|
||||
enum_val = GAIN_MAP[key]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"Unsupported gain: {gain!r}. Must be one of {sorted(GAIN_MAP)}"
|
||||
) from exc
|
||||
|
||||
self.amp.cGain_ENUM.put(enum_val)
|
||||
status = CompareStatus(self.amp.cGain_ENUM, enum_val)
|
||||
status.wait(self.timeout_for_pvwait)
|
||||
|
||||
def set_filter(
|
||||
self, value: Literal["1us", "3us", "10us", "30us", "100us", "300us", "1ms", "3ms"]
|
||||
@@ -263,7 +272,7 @@ class IonizationChamber0(PSIDeviceBase):
|
||||
self.gmes.gas2_req.set(gas2).wait(timeout=3)
|
||||
self.gmes.conc2_req.set(conc2).wait(timeout=3)
|
||||
|
||||
status = TransitionStatus(self.gmes.status.get(), [0, 1])
|
||||
status = TransitionStatus(self.gmes.status, [0, 1])
|
||||
self.cancel_on_stop(status)
|
||||
self.gmes.fill.put(1)
|
||||
if wait:
|
||||
@@ -373,6 +382,7 @@ class IonizationChamber2(IonizationChamber0):
|
||||
}
|
||||
hv_en = Dcpt(hv_en_signals)
|
||||
|
||||
|
||||
class Pips(IonizationChamber0):
|
||||
"""Pips, prefix should be 'X01DA-'."""
|
||||
|
||||
|
||||
@@ -19,6 +19,15 @@ class AmplifierGain(int, enum.Enum):
|
||||
G1E9 = 4
|
||||
|
||||
|
||||
GAIN_MAP = {
|
||||
1e6: AmplifierGain.G1E6,
|
||||
1e7: AmplifierGain.G1E7,
|
||||
5e7: AmplifierGain.G5E7,
|
||||
1e8: AmplifierGain.G1E8,
|
||||
1e9: AmplifierGain.G1E9,
|
||||
}
|
||||
|
||||
|
||||
class AmplifierFilter(int, enum.Enum):
|
||||
"""Enum class for the filter of the channel"""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -58,3 +58,10 @@ class EncoderFactors(int, enum.Enum):
|
||||
X1 = 4
|
||||
X2 = 5
|
||||
X4 = 6
|
||||
|
||||
|
||||
class EpicsMode(int, enum.Enum):
|
||||
"""Mode when sending through EPICS"""
|
||||
|
||||
MEAN = 0
|
||||
MAX = 1
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Optics Photon Shutter"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ophyd import Component as Cpt
|
||||
from ophyd import EpicsSignal, EpicsSignalRO
|
||||
from ophyd_devices import CompareStatus
|
||||
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
|
||||
|
||||
from .utils.bl_status_enum import BlStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.devicemanager import ScanInfo
|
||||
from bec_server.device_server.device_server import DeviceManagerDS
|
||||
|
||||
TIMEOUT_FOR_PV = 5
|
||||
|
||||
|
||||
class OPPhotonShutterError(Exception):
|
||||
"""Shutter specific exception"""
|
||||
|
||||
|
||||
class STATUS(int, enum.Enum):
|
||||
"""Shutter States"""
|
||||
|
||||
NOT_CLOSED = 0
|
||||
CLOSED = 1
|
||||
|
||||
|
||||
class BL_ENABLE(int, enum.Enum):
|
||||
"""Beamline enable"""
|
||||
|
||||
DISABLE = 0
|
||||
ENABLE = 1
|
||||
|
||||
|
||||
class OPPhotonShutter(PSIDeviceBase):
|
||||
"""Class for the Optics Photon Shutter"""
|
||||
|
||||
USER_ACCESS = ["open", "close"]
|
||||
|
||||
request_open = Cpt(
|
||||
EpicsSignal, suffix="OP-PSYS:SH-A-OPEN-SET", kind="config", doc="Open Shutter"
|
||||
)
|
||||
request_close = Cpt(
|
||||
EpicsSignal, suffix="OP-PSYS:SH-A-CLOSE-SET", kind="config", doc="Close Shutter"
|
||||
)
|
||||
status = Cpt(
|
||||
EpicsSignalRO,
|
||||
suffix="OP-PSYS:SH-A-CLOSE",
|
||||
kind="normal",
|
||||
auto_monitor=True,
|
||||
doc="Shutter Status",
|
||||
)
|
||||
status_string = Cpt(
|
||||
EpicsSignalRO,
|
||||
suffix="OP-PSYS:SH-A-CLOSE",
|
||||
kind="normal",
|
||||
auto_monitor=True,
|
||||
string=True,
|
||||
doc="Shutter Status",
|
||||
)
|
||||
close4bl = Cpt(EpicsSignal, suffix="FE-BST1:CLOSE4BL", kind="config", doc="Beamline enable")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
prefix: str = "",
|
||||
scan_info: ScanInfo | None = None,
|
||||
device_manager: DeviceManagerDS | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs
|
||||
)
|
||||
self.device_manager = device_manager
|
||||
|
||||
self.timeout_for_move = 10
|
||||
# Wait for connection on all components, ensure IOC is connected
|
||||
self.wait_for_connection(all_signals=True, timeout=5)
|
||||
|
||||
def open(self, force: bool = False, wait: bool = True) -> CompareStatus | None:
|
||||
"""Open the Shutter
|
||||
|
||||
Args:
|
||||
force(bool): If needed, set bl status to enable and bl enable to ENABLE, defaults to False
|
||||
|
||||
"""
|
||||
if self.status.get() == STATUS.CLOSED:
|
||||
if force:
|
||||
if self.device_manager.devices.bl_status.get() == BlStatus.OFFLINE:
|
||||
status = self.device_manager.devices.bl_status.put(BlStatus.ATTENDED)
|
||||
status.wait(timeout=TIMEOUT_FOR_PV)
|
||||
if self.close4bl.get() == BL_ENABLE.DISABLE:
|
||||
status = self.close4bl.set(BL_ENABLE.ENABLE)
|
||||
status.wait(timeout=TIMEOUT_FOR_PV)
|
||||
self.request_open.put(1)
|
||||
status = CompareStatus(self.status, STATUS.NOT_CLOSED, timeout=self.timeout_for_move)
|
||||
if wait:
|
||||
status.wait(timeout=self.timeout_for_move)
|
||||
return None
|
||||
return status
|
||||
return None
|
||||
|
||||
def close(self, wait: bool = True) -> CompareStatus | None:
|
||||
"""Close the Shutter"""
|
||||
if self.status.get() == STATUS.NOT_CLOSED:
|
||||
self.request_close.put(1)
|
||||
status = CompareStatus(self.status, STATUS.CLOSED, timeout=self.timeout_for_move)
|
||||
if wait:
|
||||
status.wait(timeout=self.timeout_for_move)
|
||||
return None
|
||||
return status
|
||||
return None
|
||||
@@ -5,8 +5,9 @@ from __future__ import annotations
|
||||
import enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from ophyd import Component as Cpt
|
||||
from ophyd import EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV
|
||||
from ophyd import EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV, Signal
|
||||
from ophyd.status import DeviceStatus
|
||||
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
|
||||
from ophyd_devices.utils.errors import DeviceStopError
|
||||
@@ -14,6 +15,8 @@ from ophyd_devices.utils.errors import DeviceStopError
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.devicemanager import ScanInfo
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class Status(int, enum.Enum):
|
||||
"""Enum class for the status field"""
|
||||
@@ -34,10 +37,67 @@ class OpMode(int, enum.Enum):
|
||||
ERRORMODE = 3
|
||||
|
||||
|
||||
N_FOILS = 38
|
||||
|
||||
|
||||
class CurrentFoilSignal(Signal):
|
||||
"""
|
||||
Soft signal reflecting the description of the foil currently
|
||||
inserted. Recomputed whenever 'ref_rb', 'status' or any of the
|
||||
`foilNN` signals on the parent device change.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._subscribed = False
|
||||
|
||||
def _foil_signal(self):
|
||||
if self.parent.status.get() != Status.INSERTED:
|
||||
return None
|
||||
idx = self.parent.ref_rb.get()
|
||||
try:
|
||||
idx = int(round(idx))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return getattr(self.parent, f"foil{idx:02d}", None)
|
||||
|
||||
def _compute(self):
|
||||
sig = self._foil_signal()
|
||||
if sig is None:
|
||||
return ""
|
||||
try:
|
||||
return sig.get()
|
||||
except Exception:
|
||||
logger.exception("current_foil: failed to read %s", sig.name)
|
||||
return ""
|
||||
|
||||
def get(self, **kwargs):
|
||||
self._readback = self._compute()
|
||||
return self._readback
|
||||
|
||||
def _on_upstream_change(self, value=None, timestamp=None, **kwargs):
|
||||
old_value = self._readback
|
||||
new_value = self._compute()
|
||||
self._readback = new_value
|
||||
self._run_subs(
|
||||
sub_type=self.SUB_VALUE, old_value=old_value, value=new_value, timestamp=timestamp
|
||||
)
|
||||
|
||||
def subscribe_to_parent_signals(self):
|
||||
if self._subscribed:
|
||||
return
|
||||
self.parent.ref_rb.subscribe(self._on_upstream_change, run=False)
|
||||
self.parent.status.subscribe(self._on_upstream_change, run=False)
|
||||
for i in range(1, N_FOILS + 1):
|
||||
getattr(self.parent, f"foil{i:02d}").subscribe(self._on_upstream_change, run=False)
|
||||
self._subscribed = True
|
||||
self._readback = self._compute()
|
||||
|
||||
|
||||
class Reffoilchanger(PSIDeviceBase):
|
||||
"""Class for the ES2 Reference Foil Changer"""
|
||||
|
||||
USER_ACCESS = ["insert"]
|
||||
USER_ACCESS = ["get_all_foils", "insert"]
|
||||
|
||||
inserted = Cpt(
|
||||
EpicsSignalRO, suffix="ES2-REF:TRY-FilterInserted", kind="config", doc="Inserted indicator"
|
||||
@@ -53,18 +113,30 @@ class Reffoilchanger(PSIDeviceBase):
|
||||
EpicsSignal, suffix="ES2-REF:SELN-FilterState-ENUM_RBV", kind="config", doc="Status"
|
||||
)
|
||||
status_string = Cpt(
|
||||
EpicsSignal, suffix="ES2-REF:SELN-FilterState-ENUM_RBV", kind="config", doc="Status", string=True
|
||||
EpicsSignal,
|
||||
suffix="ES2-REF:SELN-FilterState-ENUM_RBV",
|
||||
kind="config",
|
||||
doc="Status",
|
||||
string=True,
|
||||
)
|
||||
op_mode = Cpt(
|
||||
EpicsSignalWithRBV, suffix="ES2-REF:SELN-OpMode-ENUM", kind="config", doc="Status"
|
||||
)
|
||||
op_mode_string = Cpt(
|
||||
EpicsSignalWithRBV, suffix="ES2-REF:SELN-OpMode-ENUM", kind="config", doc="Status", string=True
|
||||
EpicsSignalWithRBV,
|
||||
suffix="ES2-REF:SELN-OpMode-ENUM",
|
||||
kind="config",
|
||||
doc="Status",
|
||||
string=True,
|
||||
)
|
||||
ref_set = Cpt(EpicsSignal, suffix="ES2-REF:SELN-SET", kind="config", doc="Requested reference")
|
||||
ref_rb = Cpt(
|
||||
EpicsSignalRO, suffix="ES2-REF:SELN-RB", kind="config", doc="Currently set reference"
|
||||
EpicsSignalRO,
|
||||
suffix="ES2-REF:SELN-RB",
|
||||
kind="config",
|
||||
doc="Currently set reference (angle)",
|
||||
)
|
||||
current_foil = Cpt(CurrentFoilSignal, value="", kind="normal", doc="Inserted reference")
|
||||
|
||||
foil01 = Cpt(EpicsSignalRO, suffix="ES-REFFOIL:FOIL01.DESC", kind="config", doc="Foil 01")
|
||||
foil02 = Cpt(EpicsSignalRO, suffix="ES-REFFOIL:FOIL02.DESC", kind="config", doc="Foil 02")
|
||||
@@ -107,6 +179,7 @@ class Reffoilchanger(PSIDeviceBase):
|
||||
|
||||
def __init__(self, *, name: str, prefix: str = "", scan_info: ScanInfo | None = None, **kwargs):
|
||||
super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs)
|
||||
self.current_foil.subscribe_to_parent_signals()
|
||||
|
||||
self.foils = [
|
||||
self.foil01,
|
||||
@@ -149,6 +222,13 @@ class Reffoilchanger(PSIDeviceBase):
|
||||
self.foil38,
|
||||
]
|
||||
|
||||
def get_all_foils(self) -> list[str]:
|
||||
"""Returns a list of strings of all available foils"""
|
||||
foils_list = []
|
||||
for foil in self.foils:
|
||||
foils_list.append(foil.get())
|
||||
return foils_list
|
||||
|
||||
def insert(self, ref: str, wait: bool = False) -> DeviceStatus:
|
||||
"""Insert a reference
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Universal Temperature Controller"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ophyd import Component as Cpt
|
||||
from ophyd import EpicsSignal, EpicsSignalRO
|
||||
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
|
||||
from univ_temp_cont_enums import (
|
||||
UnivTempContAutoTune,
|
||||
UnivTempContConnection,
|
||||
UnivTempContHeaterTcFault,
|
||||
UnivTempContOpMode,
|
||||
UnivTempContPower,
|
||||
UnivTempContRampRateUnit,
|
||||
UnivTempContSensorBreak,
|
||||
UnivTempContSetpointSelect,
|
||||
UnivTempContThermocouple,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.devicemanager import ScanInfo
|
||||
|
||||
TIMEOUT_FOR_PV = 5
|
||||
|
||||
|
||||
class UnivTempCont(PSIDeviceBase):
|
||||
"""Class for the Universal Temperature Controller"""
|
||||
|
||||
temperature = Cpt(EpicsSignalRO, suffix="TEMPERATURE", kind="normal", doc="Temperature")
|
||||
timestamp = Cpt(EpicsSignalRO, suffix="TIMESTAMP", kind="omitted", doc="Current timestamp")
|
||||
setpoint = Cpt(EpicsSignalRO, suffix="SETPOINT", kind="normal", doc="Setpoint on Controller")
|
||||
target_setpoint = Cpt(
|
||||
EpicsSignal, suffix="TARGET-SETPOINT", kind="normal", doc="Target Setpoint"
|
||||
)
|
||||
output_power = Cpt(EpicsSignalRO, suffix="OUTPUT-POWER", kind="normal", doc="Output Power %")
|
||||
ramp_rate = Cpt(EpicsSignal, suffix="RAMP-RATE", kind="normal", doc="Ramp rate")
|
||||
pb = Cpt(EpicsSignal, suffix="PB", kind="config", doc="Controller PB value")
|
||||
ti = Cpt(EpicsSignal, suffix="TI", kind="config", doc="Controller TI value")
|
||||
td = Cpt(EpicsSignal, suffix="TD", kind="config", doc="Controller TD value")
|
||||
op_mode = Cpt(EpicsSignal, suffix="OP-MODE", kind="normal", doc="Operation mode")
|
||||
setpoint_select = Cpt(
|
||||
EpicsSignal, suffix="SETPOINT-SELECT", kind="config", doc="Local or remote setpoint"
|
||||
)
|
||||
ramp_rate_unit = Cpt(EpicsSignal, suffix="RAMP-RATE-UNIT", kind="config", doc="Ramp rate unit")
|
||||
autotune = Cpt(EpicsSignal, suffix="AUTOTUNE", kind="config", doc="Autotune function")
|
||||
sensor_break = Cpt(
|
||||
EpicsSignalRO, suffix="SENSOR-BREAK", kind="normal", doc="Thermocouple Status"
|
||||
)
|
||||
thermocouple = Cpt(EpicsSignal, suffix="THERMOCOUPLE", kind="config", doc="Thermocouple type")
|
||||
power = Cpt(EpicsSignal, suffix="POWER", kind="normal", doc="Power status")
|
||||
controller_type = Cpt(EpicsSignalRO, suffix="TYPE", kind="config", doc="Controller type")
|
||||
heartbeat = Cpt(EpicsSignalRO, suffix="HEARTBEAT", kind="normal", doc="Heartbeat")
|
||||
connection = Cpt(
|
||||
EpicsSignalRO, suffix="CONNECTION", kind="normal", doc="Connection to Eurotherm Controller"
|
||||
)
|
||||
heater_temperature = Cpt(
|
||||
EpicsSignalRO,
|
||||
suffix="HEATER-TEMPERATURE",
|
||||
kind="normal",
|
||||
doc="Temperature of heating element",
|
||||
)
|
||||
heater_tc_fault = Cpt(
|
||||
EpicsSignalRO,
|
||||
suffix="HEATER-TC-FAULT",
|
||||
kind="normal",
|
||||
doc="TC Fault inside heating element",
|
||||
)
|
||||
|
||||
def __init__(self, *, name: str, prefix: str = "", scan_info: ScanInfo | None = None, **kwargs):
|
||||
super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs)
|
||||
|
||||
self.timeout_for_pvwait = 5
|
||||
# Wait for connection on all components, ensure IOC is connected
|
||||
self.wait_for_connection(all_signals=True, timeout=5)
|
||||
|
||||
def on_connected(self) -> None:
|
||||
"""
|
||||
Called after the device is connected and its signals are connected.
|
||||
Default values for signals should be set here.
|
||||
"""
|
||||
if self.setpoint_select != UnivTempContSetpointSelect.REMOTE:
|
||||
self.setpoint_select.set(UnivTempContSetpointSelect.REMOTE).wait(
|
||||
timeout=self.timeout_for_pvwait
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Enums for the universal temperature controller"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class UnivTempContOpMode(int, Enum):
|
||||
"""Class for operating mode"""
|
||||
|
||||
AUTO = 0
|
||||
MANUAL = 1
|
||||
STANDBY = 2
|
||||
|
||||
|
||||
class UnivTempContSetpointSelect(int, Enum):
|
||||
"""Class for setpoint selection"""
|
||||
|
||||
LOCAL = 0
|
||||
REMOTE = 1
|
||||
|
||||
|
||||
class UnivTempContRampRateUnit(int, Enum):
|
||||
"""Class for ramp rate unit selection"""
|
||||
|
||||
DEG_C_MIN = 0
|
||||
DEG_C_H = 1
|
||||
DEG_C_S = 2
|
||||
|
||||
|
||||
RAMP_RATE_MAP = {
|
||||
"°C/min": UnivTempContRampRateUnit.DEG_C_MIN,
|
||||
"°C/h": UnivTempContRampRateUnit.DEG_C_H,
|
||||
"°C/s": UnivTempContRampRateUnit.DEG_C_S,
|
||||
}
|
||||
|
||||
|
||||
class UnivTempContAutoTune(int, Enum):
|
||||
"""Class for autotune function"""
|
||||
|
||||
NOTUNE = 0
|
||||
TUNE = 1
|
||||
|
||||
|
||||
class UnivTempContSensorBreak(int, Enum):
|
||||
"Class for sensor break status"
|
||||
|
||||
GOOD = 0
|
||||
SENSOR_BREAK = 1
|
||||
|
||||
|
||||
class UnivTempContThermocouple(int, Enum):
|
||||
"""Class for thermocouple type"""
|
||||
|
||||
TYPE_J = 0
|
||||
Type_K = 1
|
||||
|
||||
|
||||
class UnivTempContPower(int, Enum):
|
||||
"""Class for power mode"""
|
||||
|
||||
AUTO = 0
|
||||
STANDBY = 1
|
||||
CONFIGURATION = 2
|
||||
|
||||
|
||||
class UnivTempContConnection(int, Enum):
|
||||
"""Class for connection status"""
|
||||
|
||||
DISCONNECTED = 0
|
||||
CONNECTED = 1
|
||||
|
||||
|
||||
class UnivTempContHeaterTcFault(int, Enum):
|
||||
"""Class for the heater thermocouple fault"""
|
||||
|
||||
NO_FAULT = 0
|
||||
TC_OPEN_FAULT = 1
|
||||
OVER_UNDERVOLTAGE_FAULT = 2
|
||||
TC_LOW_FAULT = 3
|
||||
TC_HIGH_FAULT = 4
|
||||
COLD_JUNC_LOW_FAULT = 5
|
||||
COLD_JUNC_HIGH_FAULT = 6
|
||||
TC_RANGE_FAULT = 7
|
||||
COLD_JUNC_RNG_FAULT = 8
|
||||
@@ -0,0 +1,8 @@
|
||||
import enum
|
||||
|
||||
class BlStatus(str, enum.Enum):
|
||||
"""Beamline status enum"""
|
||||
|
||||
OFFLINE = 0
|
||||
ATTENDED = 1
|
||||
REMOTE = 2
|
||||
@@ -1,6 +1,6 @@
|
||||
from bec_server.file_writer.default_writer import DefaultFormat
|
||||
|
||||
import debye_bec.bec_widgets.widgets.digital_twin.x01da_parameters as bl
|
||||
import debye_bec.bec_ipython_client.plugins.digital_twin_core.x01da_parameters as bl
|
||||
|
||||
|
||||
class DebyeNexusStructure(DefaultFormat):
|
||||
@@ -236,7 +236,9 @@ class DebyeNexusStructure(DefaultFormat):
|
||||
self.configuration.get("nidaq", {}).get("nidaq_add_chans", {}).get("value")
|
||||
)
|
||||
|
||||
rle = self.configuration.get("nidaq", {}).get("nidaq_enable_compression", {}).get("value")
|
||||
rle = (
|
||||
self.configuration.get("nidaq", {}).get("nidaq_enable_compression", {}).get("value")
|
||||
)
|
||||
|
||||
measurement_mode = entry.create_group(name="mode")
|
||||
measurement_mode.attrs["NX_class"] = "NX_CHAR"
|
||||
@@ -288,6 +290,14 @@ class DebyeNexusStructure(DefaultFormat):
|
||||
)
|
||||
ref_trans.attrs["NX_class"] = "NX_CHAR"
|
||||
|
||||
foil = (
|
||||
self.device_manager.devices.reffoilchanger.read(cached=True)
|
||||
.get("reffoilchanger_current_foil")
|
||||
.get("value")
|
||||
)
|
||||
ref_foil = ai2ai4_active.create_dataset(name="Reference", data=foil)
|
||||
ref_foil.attrs["NX_class"] = "NX_CHAR"
|
||||
|
||||
main_data = entry.create_group(name="data")
|
||||
main_data.attrs["NX_class"] = "NXdata"
|
||||
|
||||
|
||||
Reference in new issue
Block a user