More BEC like tomcat scans

This commit is contained in:
gac-x05la
2024-11-07 15:21:47 +01:00
committed by mohacsi_i
parent 77b50efa9f
commit 9a143e9ac2
7 changed files with 482 additions and 48 deletions
@@ -99,7 +99,7 @@ es1_psod:
onFailure: buffer
readOnly: false
readoutPriority: monitored
softwareTrigger: false
softwareTrigger: true
es1_ddaq:
@@ -75,6 +75,7 @@ class AerotechPsoDistanceMixin(CustomDeviceMixin):
def on_trigger(self) -> None | DeviceStatus:
"""Fire a single PSO event (i.e. manual software trigger)"""
# Only trigger if distance was set to invalid
logger.warning(f"[{self.parent.name}] Triggerin...")
if self.parent.dstDistanceVal.get() == 0:
status = self.parent._eventSingle.set(1, settle_time=0.1)
return status
+1 -1
View File
@@ -55,7 +55,7 @@ class AerotechTasksMixin(CustomDeviceMixin):
settle_time = 0.2
if self.parent._is_configured:
if self.parent._text_to_execute is not None:
status = self.parent._execute.set(self._text_to_execute, settle_time=settle_time)
status = self.parent._execute.set(self.parent._text_to_execute, settle_time=settle_time)
else:
status = self.parent.switch.set("Run", settle_time=settle_time)
else:
+61 -45
View File
@@ -36,7 +36,7 @@ class StdDaqMixin(CustomDeviceMixin):
# Fish out our configuration from scaninfo (via explicit or generic addressing)
scanparam = self.parent.scaninfo.scan_msg.info
alias = self.parent.parent.name if self.parent.parent is not None else self.parent.name
logger.warning(f"[{alias}] Scan parameters:\n{scanparam}")
# logger.warning(f"[{alias}] Scan parameters:\n{scanparam}")
d = {}
if 'kwargs' in scanparam:
scanargs = scanparam['kwargs']
@@ -52,6 +52,10 @@ class StdDaqMixin(CustomDeviceMixin):
# Perform bluesky-style configuration
if len(d) > 0:
# Stop if current status is not idle
if self.parent.state() != "idle":
self.parent.safestop()
# Configure new run (will restart the stdDAQ)
logger.warning(f"[{self.parent.name}] Configuring with:\n{d}")
self.parent.configure(d=d)
@@ -64,19 +68,9 @@ class StdDaqMixin(CustomDeviceMixin):
file_path = self.parent.file_path.get()
num_images = self.parent.num_images.get()
message = {"command": "start", "path": file_path, "n_image": num_images}
# FIXME: This should be simplified once we don't crash the DAQ with stop
ii = 0
self.parent.connect()
while True:
try:
reply = self.parent.message(message)
except ConnectionClosedOK:
# # NOTE: Sometimes the stdDAQ rejects both start and stop commands, needs nuking
# self.parent.restart()
# sleep(0.5)
self.parent.connect()
reply = self.parent.message(message)
reply = self.parent.message(message)
if reply is not None:
reply = json.loads(reply)
@@ -106,14 +100,8 @@ class StdDaqMixin(CustomDeviceMixin):
def on_unstage(self):
""" Stop a running acquisition and close connection
"""
if self.parent._wsclient is None:
self.parent.connect()
try:
self.parent.message({"command": "stop"}, wait_reply=False)
except (ConnectionClosedOK, ConnectionClosedError):
self.parent.connect()
self.parent.message({"command": "stop"}, wait_reply=False)
self.parent.message({"command": "stop_all"}, wait_reply=False)
except (RuntimeError, TypeError):
# The poller thread locks recv raising a RuntimeError
pass
@@ -136,12 +124,12 @@ class StdDaqMixin(CustomDeviceMixin):
for msg in self.parent._wsclient:
message = json.loads(msg)
self.parent.status.put(message["status"], force=True)
logger.warning(f"[{self.parent.name}] {message['status']}")
logger.info(f"[{self.parent.name}] Pushed status: {message['status']}")
except (ConnectionClosedError, ConnectionClosedOK, AssertionError):
# Libraty throws theese after connection is closed
return
except Exception as ex:
logger.warning(f"[{self.parent.name}] {ex}")
logger.warning(f"[{self.parent.name}] Exception in polling: {ex}")
return
finally:
self._mon = None
@@ -164,7 +152,7 @@ class StdDaqClient(PSIDeviceBase):
"""
# pylint: disable=too-many-instance-attributes
custom_prepare_cls = StdDaqMixin
USER_ACCESS = ["set_daq_config", "get_daq_config", "safestop", "restart", "connect"]
USER_ACCESS = ["set_daq_config", "get_daq_config", "safestop", "restart", "connect", "message", "state"]
_wsclient = None
# Status attributes
@@ -236,19 +224,23 @@ class StdDaqClient(PSIDeviceBase):
"The stdDAQ websocket interface refused connection 5 times.")
logger.debug(f"[{self.name}] Connected to DAQ after {num_retry} tries")
def message(self, message: dict, timeout=1, wait_reply=True):
def message(self, message: dict, timeout=1, wait_reply=True) -> None | str:
"""Send a message to the StdDAQ and receive a reply
Note: finishing acquisition means StdDAQ will close connection, so
there's no idle state polling.
"""
# Connect if client was destroyed
if self._wsclient is None:
self.connect()
# Send message (reopen connection if needed)
msg = json.dumps(message) if isinstance(message, dict) else str(message)
try:
msg = json.dumps(message) if isinstance(message, dict) else str(message)
self._wsclient.send(msg)
except (ConnectionClosedError, ConnectionClosedOK) as ex:
print(ex)
# self.connect()
except (ConnectionClosedError, ConnectionClosedOK, AttributeError) as ex:
# Re-connect if the connection was closed
self.connect()
self._wsclient.send(msg)
# Wait for reply
@@ -257,8 +249,8 @@ class StdDaqClient(PSIDeviceBase):
try:
reply = self._wsclient.recv(timeout)
return reply
except (ConnectionClosedError, ConnectionClosedOK, TimeoutError) as ex:
print(ex)
except (ConnectionClosedError, ConnectionClosedOK, TimeoutError, RuntimeError) as ex:
logger.error(f"[{self.name}] Error in receiving ws reply: {ex}")
return reply
def configure(self, d: dict = None):
@@ -308,6 +300,10 @@ class StdDaqClient(PSIDeviceBase):
# Restart the DAQ if resolution changed
cfg = self.get_daq_config()
if cfg['image_pixel_height'] != self.cfg_pixel_height.get() or cfg['image_pixel_width'] != self.cfg_pixel_width.get():
# Stop if current status is not idle
if self.state() != "idle":
self.safestop()
# Stop running acquisition
self.unstage()
# Update retrieved config
@@ -359,24 +355,44 @@ class StdDaqClient(PSIDeviceBase):
cfg = self.get_daq_config()
self.set_daq_config(cfg)
def safestop(self):
"""
The current stdDAQ refuses connection if another session is running. This is safety so
we don't accidentally kill a running exposure. But this also means that we have to wait
until a dead session dies of timeout.
def state(self) -> str | None:
""" Querry the current system state"""
r = self.message({'command': 'status'}, wait_reply=True)
if r is None:
return None
else:
r = json.loads(r)
return r['status']
NOTE: REST reconfiguration restarts with systemd and can corrupt currently written files.
def safestop(self, timeout=5):
""" Stops a running acquisition
REST reconfiguration restarts with systemd and can corrupt currently written files.
"""
try:
if self._wsclient is not None:
self._wsclient.close()
self._wsclient = connect(self.ws_url.get())
msg = json.dumps({"command": "stop"})
self._wsclient.send(msg)
except (ConnectionClosedError, ConnectionClosedOK, ConnectionRefusedError):
pass
self._staged = Staged.no
sleep(1)
# Retries to steal connection from poller
state = self.state()
for rr in range(5):
try:
r = self.message({"command": "stop_all"})
if r is not None:
r = json.loads(r)
logger.warning(f"[{self.name}] Stop-all command {r['status']}")
else:
# NOTE: stdDAQ just closes connection if idle
logger.warning(f"[{self.name}] Stop-all command unknown")
finally:
sleep(0.2)
# Wait until status is back to idle
if timeout > 0.2:
wait_time = 0
while wait_time < timeout/5:
state = self.state()
if state is not None:
if state in ['idle', 'stoped']:
return
sleep(0.2)
wait_time += 0.2
# Automatically connect to microXAS testbench if directly invoked
+2 -1
View File
@@ -68,7 +68,8 @@ class EpicsMotorMR(EpicsMotor):
except UnknownStatusFailure:
status = DeviceStatus(self)
status.set_finished()
return status
# return status
raise
class EpicsMotorEC(EpicsMotorMR):
+1
View File
@@ -1,2 +1,3 @@
from .tutorial_fly_scan import AcquireDark, AcquireFlat, TutorialFlyScanContLine
from .tomcat_scanbase import SnapAndStepScanBase, GigaStepScanBase, SequenceScanBase, SimpleFlyer
from .tomcat_scans import TomcatStepScan, TomcatSnapNStep, TomcatSimpleSequence
+415
View File
@@ -0,0 +1,415 @@
# -*- coding: utf-8 -*-
""" Tomcat scan base class examples
A collection of example scan base classes using Automation1 rotation stage,
GigaFrost camera and the StandardDAQ pipeline.
Created on Mon Sep 16 16:45:11 2024
@author: mohacsi_i
"""
import jinja2
import os
import time
from bec_lib import bec_logger
from bec_server.scan_server.scans import AsyncFlyScanBase, ScanBase
logger = bec_logger.logger
class TomcatStepScan(ScanBase):
"""Simple software step scan forTomcat
Example class for simple BEC-based step scans using the low-level API. All it does is
translate conventional kwargs to tomcat specific naming scheme.
Example
-------
>>> scans.gigastep(scan_start=-25, scan_end=155, steps=180, exp_time=0.005, exp_burst=5)
"""
scan_name = "tomcatstepscan"
required_kwargs = ["scan_start", "scan_end", "steps"]
gui_config = {
"Movement parameters": ["steps"],
"Acquisition parameters": ["exp_time", "burst_at_each_point", "roix", "roiy"],
}
def _get_scan_motors(self):
self.scan_motors = ["es1_roty"]
def __init__(
self,
scan_start: float,
scan_end: float,
steps: int,
exp_time=0.005,
settling_time=0.2,
burst_at_each_point=1,
roix=2016,
roiy=2016,
sync="event",
**kwargs,
):
# Converting generic kwargs to tomcat device configuration parameters
# Used by gigafrost
kwargs['parameter']['kwargs']["exposure_time_ms"] = 1000 * exp_time
kwargs['parameter']['kwargs']["exposure_period_ms"] = 2 * 1000 * exp_time
kwargs['parameter']['kwargs']["exposure_num_burst"] = burst_at_each_point
kwargs['parameter']['kwargs']["image_width"] = roix
kwargs['parameter']['kwargs']["image_height"] = roiy
# Used by stdDAQ and DDC
kwargs['parameter']['kwargs']["num_points_total"] = burst_at_each_point * (steps + 1)
t_modes = {"pso": 0, "event": 1, "inp0": 2, "inp1": 4}
ddc_trigger = t_modes[sync]
kwargs['parameter']['kwargs']["ddc_trigger"] = ddc_trigger
# Use PSO trigger
kwargs['parameter']['kwargs']["pso_wavemode"] = "pulsed"
super().__init__(
exp_time=exp_time,
settling_time=settling_time,
relative=False,
burst_at_each_point=1,
optim_trajectory=None,
**kwargs,
)
# For position calculation
self.motor = "es1_roty"
self.scan_start = scan_start
self.scan_end = scan_end
self.scan_steps = steps
self.scan_stepsize = (scan_end - scan_start) / steps
def _calculate_positions(self) -> None:
"""Pre-calculate scan positions"""
for ii in range(self.scan_steps + 1):
self.positions.append(self.scan_start + ii * self.scan_stepsize)
class TomcatSnapNStep(AsyncFlyScanBase):
"""Simple software step scan forTomcat
Example class for simple BEC-based step scans using the low-level API. All it does is
translate conventional kwargs to tomcat specific naming scheme.
Example
-------
>>> scans.tomcatsnapnstepscan(scan_start=-25, scan_end=155, steps=180, exp_time=0.005, exp_burst=5)
"""
scan_name = "tomcatsnapnstepscan"
required_kwargs = ["scan_start", "scan_end", "steps"]
gui_config = {
"Movement parameters": ["steps"],
"Acquisition parameters": ["exp_time", "burst_at_each_point", "roix", "roiy"],
}
def _get_scan_motors(self):
self.scan_motors = ["es1_roty"]
def __init__(
self,
scan_start: float,
scan_end: float,
steps: int,
exp_time=0.005,
settling_time=0.2,
burst_at_each_point=1,
roix=2016,
roiy=2016,
sync="event",
**kwargs,
):
# Converting generic kwargs to tomcat device configuration parameters
# Used by gigafrost
kwargs['parameter']['kwargs']["exposure_time_ms"] = 1000 * exp_time
kwargs['parameter']['kwargs']["exposure_period_ms"] = 2 * 1000 * exp_time
kwargs['parameter']['kwargs']["exposure_num_burst"] = burst_at_each_point
kwargs['parameter']['kwargs']["image_width"] = roix
kwargs['parameter']['kwargs']["image_height"] = roiy
# Used by stdDAQ and DDC
kwargs['parameter']['kwargs']["num_points_total"] = burst_at_each_point * (steps + 1)
t_modes = {"pso": 0, "event": 1, "inp0": 2, "inp1": 4}
ddc_trigger = t_modes[sync]
kwargs['parameter']['kwargs']["ddc_trigger"] = ddc_trigger
# Use PSO trigger (disable triggering)
kwargs['parameter']['kwargs']["pso_distance"] = 0
# For position calculation
self.scan_start = scan_start
self.scan_end = scan_end
self.scan_steps = steps
self.scan_stepsize = (scan_end - scan_start) / steps
self.scan_ntotal = burst_at_each_point * (steps + 1)
# Aerotech DDC settings: Internal event trigger: PsoEvent = 1
p_modes = {"pso": "PsoOutput", "event": "PsoEvent", "inp0": "HighSpeedInput0RisingEdge", "inp1": "HighSpeedInput1RisingEdge",}
filename = "AerotechSnapAndStepTemplate.ascript"
filesubs = {
"startpos": self.scan_start,
"stepsize": self.scan_stepsize,
"numsteps": self.scan_steps,
"exptime": 2 * exp_time * burst_at_each_point,
"settling": settling_time,
"psotrigger": p_modes[sync],
"npoints": self.scan_ntotal,
}
filetext = self.render_file(filename, filesubs)
# Task inteface
kwargs['parameter']['kwargs']["script_text"] = filetext
kwargs['parameter']['kwargs']["script_file"] = "bec.ascript"
super().__init__(
exp_time=exp_time,
settling_time=settling_time,
relative=False,
burst_at_each_point=1,
optim_trajectory=None,
**kwargs,
)
def render_file(self, filename, filesubs):
"""Prepare action: render AeroScript file"""
# Load the test file
if filename is not None:
filename = os.path.join(
os.path.dirname(__file__), "../devices/aerotech/" + filename
)
logger.info(f"Attempting to load file {filename}")
with open(filename) as f:
templatetext = f.read()
# Substitute jinja template
tm = jinja2.Template(templatetext)
filetext = tm.render(scan=filesubs)
return filetext
def scan_core(self):
"""The actual scan routine"""
print("TOMCAT Running scripted scan (via Jinjad AeroScript)")
t_start = time.time()
# Complete
# FIXME: this will swallow errors
# yield from self.stubs.complete(device="es1_tasks")
st = yield from self.stubs.send_rpc_and_wait("es1_tasks", "complete")
st.wait()
task_states = yield from self.stubs.send_rpc_and_wait("es1_tasks", "taskStates.get")
if task_states[4] == 8:
raise RuntimeError(f"Task {4} finished in ERROR state")
t_end = time.time()
t_elapsed = t_end - t_start
print(f"Elapsed scan time: {t_elapsed}")
time.sleep(0.5)
# Collect
# if self.daqname is not None and self.daqmode=="collect":
# print("TOMCAT Collecting scripted scan results (from EPICS)")
# positions = yield from self.stubs.send_rpc_and_wait(self.daqname, "collect")
# logger.info(f"Finished scan with collected positions: {positions}")
def cleanup(self):
"""Set scan progress to 1 to finish the scan"""
self.num_pos = 1
return super().cleanup()
class TomcatSimpleSequence(AsyncFlyScanBase):
"""Simple software step scan forTomcat
Example class for simple BEC-based step scans using the low-level API. All it does is
translate conventional kwargs to tomcat specific naming scheme.
Example
-------
>>> scans.tomcatsimplesequencescan(33, 180, 180, exp_time=0.005, exp_frames=1800, repeats=10)
"""
scan_name = "tomcatsimplesequencescan"
scan_report_hint = "table"
required_kwargs = ["scan_start", "gate_high", "gate_low"]
gui_config = {
"Movement parameters": ["scan_start", "repeats", "repmode"],
"Acquisition parameters": [
"gate_high",
"gate_low",
"exp_time",
"exp_frames",
"roix",
"roiy",
"sync",
],
}
def _get_scan_motors(self):
self.scan_motors = ["es1_roty"]
def __init__(
self,
scan_start: float,
gate_high: float,
gate_low: float,
repeats: int=1,
repmode: str="PosNeg",
exp_time: float=0.005,
exp_frames: float=180,
roix: int=2016,
roiy: int=2016,
sync: str="pso",
**kwargs,
):
# Auto-setup configuration parameters from input
self.scan_start = scan_start
self.gate_high = gate_high
self.gate_low = gate_low
self.scan_repnum = repeats
self.scan_repmode = repmode.upper()
self.exp_time = exp_time
self.exp_frames = exp_frames
# Synthetic values
self.scan_velocity = gate_high / (exp_time * exp_frames)
self.scan_acceleration = 500
self.scan_safedistance = 10
self.scan_accdistance = (
self.scan_safedistance
+ 0.5 * self.scan_velocity * self.scan_velocity / self.scan_acceleration
)
self.scan_ntotal = exp_frames * repeats
if self.scan_repmode in ("POS", "NEG"):
self.scan_range = repeats * (gate_high + gate_low)
elif self.scan_repmode in ("POSNEG", "NEGPOS"):
self.scan_range = gate_high + gate_low
else:
raise RuntimeError(f"Unsupported repetition mode: {repmode}")
# Converting generic kwargs to tomcat device configuration parameters
# Used by gigafrost
kwargs['parameter']['kwargs']["exposure_time_ms"] = 1000 * exp_time
kwargs['parameter']['kwargs']["exposure_period_ms"] = 2 * 1000 * exp_time
kwargs['parameter']['kwargs']["exposure_num_burst"] = exp_frames
kwargs['parameter']['kwargs']["image_width"] = roix
kwargs['parameter']['kwargs']["image_height"] = roiy
# Used by stdDAQ and DDC
kwargs['parameter']['kwargs']["num_points_total"] = exp_frames * (self.scan_repnum + 1)
t_modes = {"pso": 0, "event": 1, "inp0": 2, "inp1": 4}
ddc_trigger = t_modes[sync]
kwargs['parameter']['kwargs']["ddc_trigger"] = ddc_trigger
# Use PSO trigger (disable triggering)
kwargs['parameter']['kwargs']["pso_distance"] = 0
# Aerotech DDC settings: Internal event trigger: PsoEvent = 1
p_modes = {"pso": "PsoOutput", "event": "PsoEvent", "inp0": "HighSpeedInput0RisingEdge", "inp1": "HighSpeedInput1RisingEdge",}
filename = "AerotechSimpleSequenceTemplate.ascript"
filesubs = {
"startpos": self.scan_start,
"scanrange": self.scan_range,
"nrepeat": self.scan_repnum,
"scanvel": self.scan_velocity,
"scanacc": self.scan_acceleration,
"accdist": self.scan_accdistance,
"npoints": self.scan_ntotal,
"scandir": self.scan_repmode.upper(),
"psotrigger": p_modes[sync],
}
filesubs = self.get_positions(filesubs)
filetext = self.render_file(filename, filesubs)
# Task inteface
kwargs['parameter']['kwargs']["script_text"] = filetext
kwargs['parameter']['kwargs']["script_file"] = "bec.ascript"
super().__init__(
exp_time=exp_time,
settling_time=0.5,
relative=False,
burst_at_each_point=1,
optim_trajectory=None,
**kwargs,
)
def render_file(self, filename, filesubs):
"""Prepare action: render AeroScript file"""
# Load the test file
if filename is not None:
filename = os.path.join(
os.path.dirname(__file__), "../devices/aerotech/" + filename
)
logger.info(f"Attempting to load file {filename}")
with open(filename) as f:
templatetext = f.read()
# Substitute jinja template
tm = jinja2.Template(templatetext)
filetext = tm.render(scan=filesubs)
return filetext
def scan_core(self):
"""The actual scan routine"""
print("TOMCAT Running scripted scan (via Jinjad AeroScript)")
t_start = time.time()
# Complete
# FIXME: this will swallow errors
# yield from self.stubs.complete(device="es1_tasks")
st = yield from self.stubs.send_rpc_and_wait("es1_tasks", "complete")
st.wait()
task_states = yield from self.stubs.send_rpc_and_wait("es1_tasks", "taskStates.get")
if task_states[4] == 8:
raise RuntimeError(f"Task {4} finished in ERROR state")
t_end = time.time()
t_elapsed = t_end - t_start
print(f"Elapsed scan time: {t_elapsed}")
time.sleep(0.5)
# Collect
# if self.daqname is not None and self.daqmode=="collect":
# print("TOMCAT Collecting scripted scan results (from EPICS)")
# positions = yield from self.stubs.send_rpc_and_wait(self.daqname, "collect")
# logger.info(f"Finished scan with collected positions: {positions}")
def get_positions(self, filesubs: dict={}):
# Fill PSO vectors according to scan direction
# NOTE: Distance counter is bidirectional
pso_bounds_pos = []
pso_bounds_neg = []
if self.scan_repmode in ("pos", "Pos", "POS", "neg", "Neg", "NEG"):
pso_bounds_pos.append(self.scan_accdistance)
for ii in range(self.scan_repnum):
pso_bounds_pos += [self.gate_high, self.gate_low]
pso_bounds_pos = pso_bounds_pos[:-1]
if self.scan_repmode in ("posneg", "PosNeg", "POSNEG", "negpos", "NegPos", "NEGPOS"):
pso_bounds_pos = [self.scan_accdistance, self.gate_high]
pso_bounds_neg = [self.scan_accdistance + self.gate_low, self.gate_high]
filesubs["psoBoundsPos"] = pso_bounds_pos
filesubs["psoBoundsNeg"] = pso_bounds_neg
filesubs["psoBoundsPosSize"] = len(pso_bounds_pos)
filesubs["psoBoundsNegSize"] = len(pso_bounds_neg)
return filesubs
def cleanup(self):
"""Set scan progress to 1 to finish the scan"""
self.num_pos = 1
return super().cleanup()