Feat/aerotech stages #351
No files matched your search
@@ -0,0 +1,244 @@
|
||||
"""Aerotech SAXS-TT stage tools of the cSAXS session (csaxs.aerotech)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class cSAXSAerotech:
|
||||
"""Tools for the Aerotech SAXS-TT stages and the aerotech_cont_grid scan."""
|
||||
|
||||
WINDOW = "aerotech"
|
||||
PLOT = "aerotech_positions"
|
||||
GUI_RPC_TIMEOUT = 20
|
||||
SCAN_NAME = "aerotech_cont_grid"
|
||||
CAPTURE_DEVICE = "aerocapture"
|
||||
CAPTURE_SIGNAL = "aerocapture_positions"
|
||||
# exposure centres of the fast (X) and step (Y) axis, published once per line
|
||||
SIGNAL_X = "aerocapture_positions_enc6_centre"
|
||||
SIGNAL_Y = "aerocapture_positions_enc2_centre"
|
||||
COLORS = {"forward": "#1f77b4", "backward": "#ff7f0e"}
|
||||
REDRAW_INTERVAL = 0.5 # s
|
||||
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
self.gui = getattr(client, "gui", None)
|
||||
self._live: _LivePositions | None = None
|
||||
|
||||
def plot(self, scan_number: int | None = None):
|
||||
"""
|
||||
Open a window with the measured exposure centres Y versus X of
|
||||
aerotech_cont_grid, forward and backward lines in two colours.
|
||||
|
||||
Without scan_number the plot shows the last scan and then follows the running
|
||||
and the next aerotech_cont_grid scans, line by line; with scan_number it shows
|
||||
that scan. Calling it again reuses the window. The points are sent as plain
|
||||
data, so the plot shows every exposure (no downsampling), as markers.
|
||||
|
||||
Examples
|
||||
--------
|
||||
csaxs.aerotech.plot() # live, follows the scans
|
||||
csaxs.aerotech.plot(scan_number=8494) # a finished scan
|
||||
"""
|
||||
if self.gui is None:
|
||||
raise RuntimeError("No BEC GUI available: start the BEC client with the GUI.")
|
||||
self.stop_plot()
|
||||
waveform, curves = self._setup_plot()
|
||||
if scan_number is not None:
|
||||
_draw(curves, self._history_lines(scan_number))
|
||||
return waveform
|
||||
self._live = _LivePositions(self, curves)
|
||||
self._live.start()
|
||||
return waveform
|
||||
|
||||
def stop_plot(self) -> None:
|
||||
"""Stop following the scans in the plot window (the plot stays)."""
|
||||
if self._live is not None:
|
||||
self._live.stop()
|
||||
self._live = None
|
||||
|
||||
def _setup_plot(self) -> dict:
|
||||
if self.WINDOW in self.gui.windows:
|
||||
window = self.gui.windows[self.WINDOW]
|
||||
else:
|
||||
window = self.gui.new(self.WINDOW, timeout=self.GUI_RPC_TIMEOUT)
|
||||
# reuse the plot of an earlier call (new() would add a second one)
|
||||
waveform = window.widget_map().get(self.PLOT)
|
||||
if waveform is None:
|
||||
waveform = window.new("Waveform", object_name=self.PLOT, timeout=self.GUI_RPC_TIMEOUT)
|
||||
waveform.clear_all()
|
||||
waveform.title = "aerotech_cont_grid: exposure centres"
|
||||
waveform.x_label = "X centre (aerox)"
|
||||
waveform.y_label = "Y centre (aeroy)"
|
||||
waveform.lock_aspect_ratio = True
|
||||
curves = {}
|
||||
for direction, color in self.COLORS.items():
|
||||
curve = waveform.plot(x=[np.nan], y=[np.nan], label=f"{direction} lines")
|
||||
curve.set_color(color)
|
||||
curve.set_symbol("o")
|
||||
curve.set_symbol_size(4)
|
||||
curves[direction] = curve
|
||||
return waveform, curves
|
||||
|
||||
def _history_lines(self, scan_number: int) -> list[tuple[np.ndarray, np.ndarray]]:
|
||||
scan = self.client.history.get_by_scan_number(scan_number)
|
||||
if isinstance(scan, list):
|
||||
scan = scan[-1] if scan else None
|
||||
if scan is None:
|
||||
raise ValueError(f"Scan {scan_number} not found in the history.")
|
||||
return _lines_from_file(scan._msg.file_path)
|
||||
|
||||
|
||||
def _lines_from_file(file_path: str) -> list[tuple[np.ndarray, np.ndarray]]:
|
||||
"""Exposure centres per line from an aerotech_cont_grid data file."""
|
||||
with h5py.File(file_path, "r") as f:
|
||||
md = f["entry/collection/metadata/bec"]
|
||||
n = int(md["frames_per_trigger"][()])
|
||||
cap = f[f"entry/collection/devices/{cSAXSAerotech.CAPTURE_DEVICE}"]
|
||||
x = cap[f"{cSAXSAerotech.SIGNAL_X}/value"][()]
|
||||
y = cap[f"{cSAXSAerotech.SIGNAL_Y}/value"][()]
|
||||
return [(x[i : i + n], y[i : i + n]) for i in range(0, len(x), n)]
|
||||
|
||||
|
||||
def _column_order(lines: list[tuple[np.ndarray, np.ndarray]], backward: bool):
|
||||
"""
|
||||
Points of lines of one direction, ordered by cell column. X then increases along
|
||||
the curve (up to the jitter within a column), which the curve's clip-to-view
|
||||
(bisection on X) needs. Lines are in measurement order, so a backward line is
|
||||
reversed to cell order.
|
||||
"""
|
||||
cells_x = np.array([line[0][::-1] if backward else line[0] for line in lines])
|
||||
cells_y = np.array([line[1][::-1] if backward else line[1] for line in lines])
|
||||
rows = np.arange(len(lines))
|
||||
columns = [
|
||||
(cells_x[order, c], cells_y[order, c])
|
||||
for c in range(cells_x.shape[1])
|
||||
for order in [rows if c % 2 == 0 else rows[::-1]]
|
||||
]
|
||||
return np.concatenate([c[0] for c in columns]), np.concatenate([c[1] for c in columns])
|
||||
|
||||
|
||||
def _draw(curves: dict, lines: list[tuple[np.ndarray, np.ndarray]]) -> None:
|
||||
"""Even lines are forward, odd lines backward (snake), one curve per direction."""
|
||||
for direction, first in (("forward", 0), ("backward", 1)):
|
||||
selected = lines[first::2]
|
||||
if not selected or len({len(line[0]) for line in selected}) != 1:
|
||||
curves[direction].set_data([np.nan], [np.nan])
|
||||
continue
|
||||
x, y = _column_order(selected, backward=first == 1)
|
||||
curves[direction].set_data(*_markers_only(x, y))
|
||||
|
||||
|
||||
def _markers_only(x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
A NaN after every point: the curve draws no connecting lines (a non-finite value is a
|
||||
gap), only the markers. X stays finite and sorted for the clip-to-view.
|
||||
"""
|
||||
x2 = np.repeat(x, 2)
|
||||
y2 = np.full(2 * len(y), np.nan)
|
||||
y2[0::2] = y
|
||||
return x2, y2
|
||||
|
||||
|
||||
class _LivePositions:
|
||||
"""Follows aerotech_cont_grid scans and redraws the exposure centres per line."""
|
||||
|
||||
def __init__(self, parent: cSAXSAerotech, curves: dict):
|
||||
self.parent = parent
|
||||
self.connector = parent.client.connector
|
||||
self.curves = curves
|
||||
self._lock = threading.Lock()
|
||||
self._lines: list[tuple[np.ndarray, np.ndarray]] = []
|
||||
self._scan_id = None
|
||||
self._stream_topic = None
|
||||
self._dirty = threading.Event()
|
||||
self._stop = threading.Event()
|
||||
self._worker = threading.Thread(target=self._redraw_loop, daemon=True)
|
||||
|
||||
def start(self) -> None:
|
||||
self._show_last_scan()
|
||||
self.connector.register(MessageEndpoints.scan_status(), cb=self._on_scan_status)
|
||||
status = self.connector.get(MessageEndpoints.scan_status())
|
||||
if status is not None and status.status == "open":
|
||||
self._follow(status)
|
||||
self._worker.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
self.connector.unregister(MessageEndpoints.scan_status(), cb=self._on_scan_status)
|
||||
self._unfollow()
|
||||
|
||||
def _show_last_scan(self) -> None:
|
||||
try:
|
||||
self._set_lines(_lines_from_file(self.parent.client.history[-1]._msg.file_path))
|
||||
except Exception: # no scan yet, or the last one is not an aerotech_cont_grid
|
||||
pass
|
||||
|
||||
def _on_scan_status(self, msg, **_kwargs) -> None:
|
||||
status = msg.value
|
||||
if status.status == "open" and status.scan_id != self._scan_id:
|
||||
self._follow(status)
|
||||
|
||||
def _follow(self, status) -> None:
|
||||
if status.scan_name != self.parent.SCAN_NAME:
|
||||
return
|
||||
self._unfollow()
|
||||
self._scan_id = status.scan_id
|
||||
self._set_lines([])
|
||||
self._stream_topic = MessageEndpoints.device_async_signal(
|
||||
scan_id=status.scan_id,
|
||||
device=self.parent.CAPTURE_DEVICE,
|
||||
signal=self.parent.CAPTURE_SIGNAL,
|
||||
)
|
||||
# from_start: the lines published before the subscription are read too
|
||||
self.connector.register(self._stream_topic, cb=self._on_line, from_start=True)
|
||||
|
||||
def _unfollow(self) -> None:
|
||||
if self._stream_topic is not None:
|
||||
self.connector.unregister(self._stream_topic, cb=self._on_line)
|
||||
self._stream_topic = None
|
||||
|
||||
def _on_line(self, msg: dict, **_kwargs) -> None:
|
||||
signals = msg["data"].signals
|
||||
try:
|
||||
x = np.asarray(signals[self.parent.SIGNAL_X]["value"], dtype=float)
|
||||
y = np.asarray(signals[self.parent.SIGNAL_Y]["value"], dtype=float)
|
||||
except KeyError:
|
||||
return
|
||||
with self._lock:
|
||||
self._lines.append((x, y))
|
||||
self._dirty.set()
|
||||
|
||||
def _set_lines(self, lines) -> None:
|
||||
with self._lock:
|
||||
self._lines = list(lines)
|
||||
self._dirty.set()
|
||||
|
||||
def _redraw_loop(self) -> None:
|
||||
# RPC calls to the GUI are made here, never in the Redis callback thread: that
|
||||
# thread also delivers the RPC replies
|
||||
while not self._stop.is_set():
|
||||
if self._dirty.wait(timeout=self.parent.REDRAW_INTERVAL) and not self._stop.is_set():
|
||||
self._dirty.clear()
|
||||
with self._lock:
|
||||
lines = list(self._lines)
|
||||
try:
|
||||
_draw(self.curves, lines)
|
||||
except Exception as exc: # window or plot closed, GUI restarted
|
||||
# stop following instead of failing on every line of every scan
|
||||
logger.warning(
|
||||
f"csaxs.aerotech.plot: the plot is gone ({exc}); stopped following"
|
||||
" the scans. Call csaxs.aerotech.plot() to open it again."
|
||||
)
|
||||
self.stop()
|
||||
if self.parent._live is self:
|
||||
self.parent._live = None
|
||||
return
|
||||
self._stop.wait(self.parent.REDRAW_INTERVAL)
|
||||
@@ -3,6 +3,7 @@ import inspect
|
||||
from bec_lib import bec_logger
|
||||
from typeguard import typechecked
|
||||
|
||||
from csaxs_bec.bec_ipython_client.plugins.cSAXS.aerotech import cSAXSAerotech
|
||||
from csaxs_bec.bec_ipython_client.plugins.cSAXS.diagnostics import cSAXSDiagnostics
|
||||
from csaxs_bec.bec_ipython_client.plugins.cSAXS.filter_transmission import cSAXSFilterTransmission
|
||||
from csaxs_bec.bec_ipython_client.plugins.cSAXS.intensity_map_predict_gap import (
|
||||
@@ -32,6 +33,7 @@ class cSAXS(
|
||||
self.OMNYTools = OMNYTools(self.client)
|
||||
|
||||
self.diagnostics = cSAXSDiagnostics(client)
|
||||
self.aerotech = cSAXSAerotech(client)
|
||||
super().__init__(client=client)
|
||||
|
||||
# EPICS does not keep the slit offsets reliably; restore them from config
|
||||
@@ -111,6 +113,7 @@ class cSAXS(
|
||||
("csaxs.diagnostics.xbim", "XBIM diagnostics — .show_all(), .gain(val)"),
|
||||
("csaxs.diagnostics.beamstop", "Beamstop diode — .show_all(), .gain(val)"),
|
||||
("csaxs.diagnostics.polarization", "Polarization diodes — .show_all(), .gain(val)"),
|
||||
("csaxs.aerotech.plot()", "Aerotech scan: exposure centres Y vs X, live"),
|
||||
("csaxs.OMNYTools.*", "OMNY instrument tools"),
|
||||
]:
|
||||
ns.add_row(access, desc)
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
############################################################
|
||||
# SAXS-TT Aerotech test setup without beamline timing: sastt_aerotech.yaml + PandABox
|
||||
# omny-panda as line trigger delay generator (device pandatrig, layout
|
||||
# devices/panda_box/layouts/sastt_line_trigger.ini, written by the device at connect).
|
||||
# Wiring: Npaq J7 opto output (J7-25 +5 V -> J7-6, J7-7 -> TTLIN3, J7-24 -> TTLIN3 ground)
|
||||
# TTLOUT2 "shutter" (trigger edge to end of last exposure), TTLOUT3 "detector" pulses
|
||||
# (-> scope, later Npaq J12 pin 1 +, pin 2 -).
|
||||
# Keep the device list below in sync with sastt_aerotech.yaml.
|
||||
############################################################
|
||||
|
||||
aerox:
|
||||
description: Aerotech X, ALS25020 linear stage
|
||||
deviceClass: csaxs_bec.devices.aerotech.a3200.A3200Motor
|
||||
deviceConfig:
|
||||
axis_Id: X
|
||||
axis_nr: 0
|
||||
pso_input: 6
|
||||
pso_counts_divider: 1
|
||||
acceleration: 500.0
|
||||
host: pc14643.psi.ch
|
||||
port: 8000
|
||||
velocity: 5.0
|
||||
max_scan_velocity: 50.0
|
||||
egu: mm
|
||||
tolerance: 0.001
|
||||
limits:
|
||||
- -50
|
||||
- 150
|
||||
sign: 1
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- sastt_aerotech
|
||||
aeroy:
|
||||
description: Aerotech Y, AVSI125 vertical lift
|
||||
deviceClass: csaxs_bec.devices.aerotech.a3200.A3200Motor
|
||||
deviceConfig:
|
||||
axis_Id: Y
|
||||
axis_nr: 2
|
||||
pso_input: 2
|
||||
acceleration: 10.0
|
||||
host: pc14643.psi.ch
|
||||
port: 8000
|
||||
velocity: 5.0
|
||||
egu: mm
|
||||
tolerance: 0.005
|
||||
limits:
|
||||
- -10
|
||||
- 10
|
||||
sign: 1
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- sastt_aerotech
|
||||
aeroz:
|
||||
description: Aerotech Z, ATS20010 linear stage (no PSO input)
|
||||
deviceClass: csaxs_bec.devices.aerotech.a3200.A3200Motor
|
||||
deviceConfig:
|
||||
axis_Id: Z
|
||||
axis_nr: 1
|
||||
acceleration: 10.0
|
||||
host: pc14643.psi.ch
|
||||
port: 8000
|
||||
velocity: 5.0
|
||||
egu: mm
|
||||
tolerance: 0.005
|
||||
limits:
|
||||
- -100
|
||||
- 100
|
||||
sign: 1
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- sastt_aerotech
|
||||
aerorot:
|
||||
description: Aerotech U, ABRT-200 rotation stage (UU follows as gantry slave)
|
||||
deviceClass: csaxs_bec.devices.aerotech.a3200.A3200Motor
|
||||
deviceConfig:
|
||||
axis_Id: U
|
||||
axis_nr: 3
|
||||
pso_input: 7
|
||||
pso_counts_divider: 40
|
||||
acceleration: 2000.0
|
||||
host: pc14643.psi.ch
|
||||
port: 8000
|
||||
velocity: 30.0
|
||||
egu: deg
|
||||
tolerance: 0.001
|
||||
limits:
|
||||
- 0
|
||||
- 0
|
||||
sign: 1
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- sastt_aerotech
|
||||
aerocapture:
|
||||
# always used by aerotech_cont_grid (positions at the start and centre of each exposure)
|
||||
description: Aerotech position capture (Npaq DATAACQ, latched on the detector trigger at UINT1)
|
||||
deviceClass: csaxs_bec.devices.aerotech.a3200.A3200PositionCapture
|
||||
deviceConfig:
|
||||
host: pc14643.psi.ch
|
||||
port: 8000
|
||||
drive_axis: X
|
||||
# DATAACQ source -> axis: 0-5 local encoder counter, 6-11 MXR channel 0-5 (max. 4 kHz)
|
||||
# keys are quoted: integer map keys break the config message (msgpack strict_map_key)
|
||||
channels:
|
||||
"6": X
|
||||
"2": Y
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: async
|
||||
deviceTags:
|
||||
- sastt_aerotech
|
||||
pandatrig:
|
||||
description: PandABox line trigger (shutter + detector pulses on the line trigger edge)
|
||||
deviceClass: csaxs_bec.devices.panda_box.panda_line_trigger.PandaLineTrigger
|
||||
deviceConfig:
|
||||
host: omny-panda.psi.ch
|
||||
port: 8888
|
||||
input_termination: 50-Ohm
|
||||
trigger_edge: rising
|
||||
# gap between the detector gates, as the DDG2 default at the beamline
|
||||
readout_time: 0.0002
|
||||
# line trigger -> first gate, as the DDG1 shutter-to-open delay at the beamline
|
||||
shutter_delay: 0.008
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- sastt_aerotech
|
||||
|
||||
############################################################
|
||||
# SmarGon goniometer (smargopolo REST API, host tt-smargopolo, port 3000), as smargon.yaml.
|
||||
# All entries share one controller (same host/port). Referencing is a deliberate operator
|
||||
# action after loading: dev.smargon.reference()
|
||||
############################################################
|
||||
|
||||
# --- Coordinator (whole-goniometer ops: reference/mode/move_scs/diagnostics) ------
|
||||
smargon:
|
||||
description: SmarGon coordinator (referencing, mode, coordinated moves)
|
||||
deviceClass: csaxs_bec.devices.smargon.smargon.SmarGon
|
||||
deviceConfig:
|
||||
host: tt-smargopolo # cSAXS beamline smargopolo REST API
|
||||
port: 3000 # REST API port (per maintainer). NB: :8080 is the web GUI, not the API
|
||||
timeout: 5.0
|
||||
deviceTags: [goniometer, smargon]
|
||||
enabled: true
|
||||
onFailure: retry
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
softwareTrigger: false
|
||||
|
||||
# --- SCS axis motors --------------------------------------------------------------
|
||||
# Limits from the MX-lab reference client (resources_2/Smargon.py) -- VERIFY per build.
|
||||
# NOTE: SHZ is not centred on 0 (home ~18); PHI is continuous (client wraps to
|
||||
# [-180,180) -- open question whether smargopolo wraps server-side).
|
||||
sgx:
|
||||
description: SmarGon SHX (sample X)
|
||||
deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor
|
||||
deviceConfig: {scs_axis: SHX, host: tt-smargopolo, port: 3000, limits: [-2, 2], tolerance: 0.001}
|
||||
deviceTags: [goniometer, smargon]
|
||||
enabled: true
|
||||
onFailure: retry
|
||||
readoutPriority: baseline
|
||||
|
||||
sgy:
|
||||
description: SmarGon SHY (sample Y)
|
||||
deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor
|
||||
deviceConfig: {scs_axis: SHY, host: tt-smargopolo, port: 3000, limits: [-2, 2], tolerance: 0.001}
|
||||
deviceTags: [goniometer, smargon]
|
||||
enabled: true
|
||||
onFailure: retry
|
||||
readoutPriority: baseline
|
||||
|
||||
sgz:
|
||||
description: SmarGon SHZ (sample Z)
|
||||
deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor
|
||||
deviceConfig: {scs_axis: SHZ, host: tt-smargopolo, port: 3000, limits: [10, 22], tolerance: 0.001}
|
||||
deviceTags: [goniometer, smargon]
|
||||
enabled: true
|
||||
onFailure: retry
|
||||
readoutPriority: baseline
|
||||
|
||||
sgchi:
|
||||
description: SmarGon CHI (goniometer tilt)
|
||||
deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor
|
||||
deviceConfig: {scs_axis: CHI, host: tt-smargopolo, port: 3000, limits: [0, 65], tolerance: 0.01}
|
||||
deviceTags: [goniometer, smargon]
|
||||
enabled: true
|
||||
onFailure: retry
|
||||
readoutPriority: baseline
|
||||
|
||||
sgphi:
|
||||
description: SmarGon PHI (goniometer rotation)
|
||||
deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor
|
||||
deviceConfig: {scs_axis: PHI, host: tt-smargopolo, port: 3000, limits: [-180, 180], tolerance: 0.01}
|
||||
deviceTags: [goniometer, smargon]
|
||||
enabled: true
|
||||
onFailure: retry
|
||||
readoutPriority: baseline
|
||||
@@ -25,14 +25,17 @@ detectors:
|
||||
#sastt:
|
||||
# - !include ./sastt.yaml
|
||||
|
||||
sastt:
|
||||
- !include ./sastt_aerotech.yaml
|
||||
|
||||
# flomni:
|
||||
# - !include ./ptycho_flomni.yaml
|
||||
|
||||
#omny:
|
||||
# - !include ./ptycho_omny.yaml
|
||||
|
||||
lamni:
|
||||
- !include ./ptycho_lamni.yaml
|
||||
# lamni:
|
||||
# - !include ./ptycho_lamni.yaml
|
||||
|
||||
# user setup:
|
||||
# - !include ./user_setup.yaml
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
############################################################
|
||||
# SAXS-TT Aerotech stages: Npaq-B-40B-40B/ULTRA, A3200 6.04.004 on the Windows PC
|
||||
# pc14643.psi.ch (FireWire to the Npaq). BEC connects to the A3200 ASCII command interface.
|
||||
# Values from the parameter file 153722-A-1-1.prma (2026-09-25):
|
||||
# CommandSetup = 0x00010004, CommandPort = 8000, GlobalDoubles = 255
|
||||
# Npaq axis index: X 0, Z 1, Y 2, U 3, UU 4 (gantry slave of U), ZZ 5
|
||||
# feedback: X, U, UU, ZZ sine (MXR), Y square wave (local counter 2),
|
||||
# Z position from MXR channel 5 (ZZ input), velocity from local counter 1
|
||||
# PSO: X -> emulated quadrature 6 (divider 1), U -> 7 (divider 40), Y -> local counter 2;
|
||||
# Z has no emulated quadrature output, so no PSO line trigger on Z.
|
||||
# PSO interlock defeated (IOSetup 0x80) on all axes.
|
||||
# controller soft limits: X -50..150, Z -100..100, Y -10..10 mm (active after home)
|
||||
# tolerance: X 1 um, Z/Y 5 um (InPositionDistance X 10 nm, Z/Y 5 um)
|
||||
# velocity / acceleration below = DefaultSpeed / DefaultRampRate (MaxSpeedClamp:
|
||||
# X 200, Z 10, Y 10 mm/s, U 720 deg/s), except X: acceleration 500 mm/s^2 (1000 left a
|
||||
# speed drift in the lines at 20 mm/s, 2026-10-29), scans up to max_scan_velocity 50 mm/s
|
||||
# Wiring for aerotech_cont_grid: PSO output (J7 "PSO INTERFACE") -> DDG1 EXT,
|
||||
# detector trigger -> UINT1 (J12 "MISC I/O").
|
||||
# UU (ADRS-150 slipring follower) follows U as gantry slave and is not a BEC device.
|
||||
############################################################
|
||||
|
||||
aerox:
|
||||
description: Aerotech X, ALS25020 linear stage
|
||||
deviceClass: csaxs_bec.devices.aerotech.a3200.A3200Motor
|
||||
deviceConfig:
|
||||
axis_Id: X
|
||||
axis_nr: 0
|
||||
pso_input: 6
|
||||
pso_counts_divider: 1
|
||||
acceleration: 500.0
|
||||
host: pc14643.psi.ch
|
||||
port: 8000
|
||||
velocity: 5.0
|
||||
max_scan_velocity: 50.0
|
||||
egu: mm
|
||||
tolerance: 0.001
|
||||
limits:
|
||||
- -50
|
||||
- 150
|
||||
sign: 1
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- sastt_aerotech
|
||||
aeroy:
|
||||
description: Aerotech Y, AVSI125 vertical lift
|
||||
deviceClass: csaxs_bec.devices.aerotech.a3200.A3200Motor
|
||||
deviceConfig:
|
||||
axis_Id: Y
|
||||
axis_nr: 2
|
||||
pso_input: 2
|
||||
acceleration: 10.0
|
||||
host: pc14643.psi.ch
|
||||
port: 8000
|
||||
velocity: 5.0
|
||||
egu: mm
|
||||
tolerance: 0.005
|
||||
limits:
|
||||
- -10
|
||||
- 10
|
||||
sign: 1
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- sastt_aerotech
|
||||
# aeroz:
|
||||
# description: Aerotech Z, ATS20010 linear stage (no PSO input)
|
||||
# deviceClass: csaxs_bec.devices.aerotech.a3200.A3200Motor
|
||||
# deviceConfig:
|
||||
# axis_Id: Z
|
||||
# axis_nr: 1
|
||||
# acceleration: 10.0
|
||||
# host: pc14643.psi.ch
|
||||
# port: 8000
|
||||
# velocity: 5.0
|
||||
# egu: mm
|
||||
# tolerance: 0.005
|
||||
# limits:
|
||||
# - -100
|
||||
# - 100
|
||||
# sign: 1
|
||||
# enabled: true
|
||||
# onFailure: buffer
|
||||
# readOnly: false
|
||||
# readoutPriority: baseline
|
||||
# deviceTags:
|
||||
# - sastt_aerotech
|
||||
# aerorot:
|
||||
# description: Aerotech U, ABRT-200 rotation stage (UU follows as gantry slave)
|
||||
# deviceClass: csaxs_bec.devices.aerotech.a3200.A3200Motor
|
||||
# deviceConfig:
|
||||
# axis_Id: U
|
||||
# axis_nr: 3
|
||||
# pso_input: 7
|
||||
# pso_counts_divider: 40
|
||||
# acceleration: 2000.0
|
||||
# host: pc14643.psi.ch
|
||||
# port: 8000
|
||||
# velocity: 30.0
|
||||
# egu: deg
|
||||
# tolerance: 0.001
|
||||
# limits:
|
||||
# - 0
|
||||
# - 0
|
||||
# sign: 1
|
||||
# enabled: true
|
||||
# onFailure: buffer
|
||||
# readOnly: false
|
||||
# readoutPriority: baseline
|
||||
# deviceTags:
|
||||
# - sastt_aerotech
|
||||
aerocapture:
|
||||
# always used by aerotech_cont_grid (positions at the start and centre of each exposure)
|
||||
description: Aerotech position capture (Npaq DATAACQ, latched on the detector trigger at UINT1)
|
||||
deviceClass: csaxs_bec.devices.aerotech.a3200.A3200PositionCapture
|
||||
deviceConfig:
|
||||
host: pc14643.psi.ch
|
||||
port: 8000
|
||||
drive_axis: X
|
||||
# DATAACQ source -> axis: 0-5 local encoder counter, 6-11 MXR channel 0-5 (max. 4 kHz)
|
||||
# keys are quoted: integer map keys break the config message (msgpack strict_map_key)
|
||||
channels:
|
||||
"6": X
|
||||
"2": Y
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: async
|
||||
deviceTags:
|
||||
- sastt_aerotech
|
||||
@@ -0,0 +1,122 @@
|
||||
############################################################
|
||||
# Simulated SAXS-TT Aerotech stages (sim_a3200), same devices and numbering as
|
||||
# sastt_aerotech.yaml (CountsPerUnit from the parameter file).
|
||||
# Axes start enabled and homed (sim_enabled / sim_homed in deviceConfig to change).
|
||||
############################################################
|
||||
|
||||
aerox:
|
||||
description: Aerotech X, ALS25020 linear stage
|
||||
deviceClass: csaxs_bec.devices.sim.sim_a3200.SimA3200Motor
|
||||
deviceConfig:
|
||||
axis_Id: X
|
||||
axis_nr: 0
|
||||
pso_input: 6
|
||||
pso_counts_divider: 1
|
||||
acceleration: 500.0
|
||||
host: sim-a3200-saxstt
|
||||
port: 8000
|
||||
velocity: 5.0
|
||||
max_scan_velocity: 50.0
|
||||
egu: mm
|
||||
tolerance: 0.001
|
||||
limits:
|
||||
- -50
|
||||
- 150
|
||||
sign: 1
|
||||
sim_counts_per_unit: 256000
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- sastt_aerotech
|
||||
aeroy:
|
||||
description: Aerotech Y, AVSI125 vertical lift
|
||||
deviceClass: csaxs_bec.devices.sim.sim_a3200.SimA3200Motor
|
||||
deviceConfig:
|
||||
axis_Id: Y
|
||||
axis_nr: 2
|
||||
pso_input: 2
|
||||
acceleration: 10.0
|
||||
host: sim-a3200-saxstt
|
||||
port: 8000
|
||||
velocity: 5.0
|
||||
egu: mm
|
||||
tolerance: 0.005
|
||||
limits:
|
||||
- -10
|
||||
- 10
|
||||
sign: 1
|
||||
sim_counts_per_unit: 4716.981132075472
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- sastt_aerotech
|
||||
aeroz:
|
||||
description: Aerotech Z, ATS20010 linear stage (no PSO input)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_a3200.SimA3200Motor
|
||||
deviceConfig:
|
||||
axis_Id: Z
|
||||
axis_nr: 1
|
||||
acceleration: 10.0
|
||||
host: sim-a3200-saxstt
|
||||
port: 8000
|
||||
velocity: 5.0
|
||||
egu: mm
|
||||
tolerance: 0.005
|
||||
limits:
|
||||
- -100
|
||||
- 100
|
||||
sign: 1
|
||||
sim_counts_per_unit: 5000
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- sastt_aerotech
|
||||
aerorot:
|
||||
description: Aerotech U, ABRT-200 rotation stage (UU follows as gantry slave)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_a3200.SimA3200Motor
|
||||
deviceConfig:
|
||||
axis_Id: U
|
||||
axis_nr: 3
|
||||
pso_input: 7
|
||||
pso_counts_divider: 40
|
||||
acceleration: 2000.0
|
||||
host: sim-a3200-saxstt
|
||||
port: 8000
|
||||
velocity: 30.0
|
||||
egu: deg
|
||||
tolerance: 0.001
|
||||
limits:
|
||||
- 0
|
||||
- 0
|
||||
sign: 1
|
||||
sim_counts_per_unit: 560000
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- sastt_aerotech
|
||||
aerocapture:
|
||||
description: Aerotech position capture (Npaq DATAACQ, latched on the detector trigger at UINT1)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_a3200.SimA3200PositionCapture
|
||||
deviceConfig:
|
||||
host: sim-a3200-saxstt
|
||||
port: 8000
|
||||
drive_axis: X
|
||||
# DATAACQ source -> axis: 0-5 local encoder counter, 6-11 MXR channel 0-5 (max. 4 kHz)
|
||||
# keys are quoted: integer map keys break the config message (msgpack strict_map_key)
|
||||
channels:
|
||||
"6": X
|
||||
"2": Y
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: async
|
||||
deviceTags:
|
||||
- sastt_aerotech
|
||||
@@ -0,0 +1,197 @@
|
||||
# Aerotech A3200 (SAXS-TT)
|
||||
|
||||
Hardware: Npaq-B-40B-40B/ULTRA drive rack, commanded over FireWire by the A3200 software
|
||||
(SMC) on a Windows PC. The Npaq Ethernet port is for Modbus I/O only, so BEC talks to the
|
||||
A3200 ASCII command interface on the Windows PC (TCP, default port 8000).
|
||||
|
||||
A3200 PC: `pc14643.psi.ch`, A3200 6.04.004. Parameter file `153722-A-1-1.prma`
|
||||
(2026-09-25), copy in `~/gitea/SAXS-TT_docs/prm`.
|
||||
|
||||
| BEC device | Axis | Index | Stage | Feedback | PSO input | DATAACQ source |
|
||||
|------------|------|-------|----------|----------|-----------|----------------|
|
||||
| aerox | X | 0 | ALS25020 | sine (MXR 0) | 6 (emulated quadrature, divider 1) | 6 (MXR 0) |
|
||||
| aeroz | Z | 1 | ATS20010 | position MXR 5 (ZZ input), velocity counter 1 | - | 11 (MXR 5) |
|
||||
| aeroy | Y | 2 | AVSI125 | square wave (counter 2) | 2 | 2 |
|
||||
| aerorot | U | 3 | ABRT-200 | sine (MXR 3) | 7 (emulated quadrature, divider 40) | 9 (MXR 3) |
|
||||
| - | UU | 4 | ADRS-150 | sine | - | - |
|
||||
| - | ZZ | 5 | - | encoder input used by Z | - | - |
|
||||
|
||||
UU is the gantry slave of U (`GantryMasterAxis = 3`, `GantrySetup = 1`), not a BEC device.
|
||||
`aerocapture` latches X, Z, Y, U.
|
||||
|
||||
Channel numbering on the Npaq: feedback channel = axis index in the rack unless
|
||||
`PositionFeedbackChannel` says otherwise. PSO inputs 0-5 are the local encoder counters
|
||||
(square wave), 6-8 the MXR emulated quadrature outputs (`EmulatedQuadratureChannel`, PSO
|
||||
counts = CountsPerUnit / `EmulatedQuadratureDivider` -> `pso_counts_divider`). DATAACQ
|
||||
sources 0-5 are the local counters, 6-11 the MXR channels 0-5 (max. 4 kHz trigger rate).
|
||||
|
||||
Configs: `device_configs/sastt_aerotech.yaml` (hardware),
|
||||
`device_configs/simulated_omny/simulated_sastt_aerotech.yaml` (simulation, `sim_a3200.py`).
|
||||
|
||||
Documentation: A3200 6.04.004 help (`A3200.chm`, topic "ASCII Command Interface"), copy at
|
||||
`~/gitea/SAXS-TT_docs` on the workstation.
|
||||
|
||||
## Protocol
|
||||
|
||||
- one AeroBasic line per command, terminated by `\n`
|
||||
- reply: `%` (+ data) success, `!` invalid syntax, `#` task fault (`~LASTERROR` gives the text)
|
||||
- `~STATUS (X, ProgramPositionFeedback) (X, AxisStatus) ...` reads several items at once
|
||||
- only asynchronous motion commands are used (`MOVEABS`, `HOMEASYNC`, `ABORT`); blocking
|
||||
commands like `HOME` would block the socket until they finish
|
||||
- motion commands are never retried; status reads are retried once
|
||||
|
||||
## Commissioning checklist
|
||||
|
||||
1. A3200 runs on the Windows PC, all axes enable and move in Motion Composer.
|
||||
2. Export the parameter file (`.prme`) and keep a copy.
|
||||
3. Configuration Manager: `CommandSetup = 0x00010004` (ASCII interface enabled +
|
||||
Always Send EOS), `CommandPort = 8000`, reset the controller.
|
||||
4. Windows firewall: allow inbound TCP 8000 from the BEC host.
|
||||
5. Check with `nc <host> 8000`, send `~VERSION`, expect `%6.04...`.
|
||||
6. Host, axes, velocities and limits in `sastt_aerotech.yaml` are taken from the
|
||||
parameter file; update them when the parameters change.
|
||||
7. Check `ProgramPositionFeedback` matches the position shown in Motion Composer.
|
||||
8. UU follows U as gantry slave: check the slip ring cabling before the first rotation.
|
||||
|
||||
## Scan: aerotech_cont_grid
|
||||
|
||||
`scans_v4/aerotech_cont_grid.py`, arguments as `cont_grid` plus options:
|
||||
|
||||
```python
|
||||
scans.aerotech_cont_grid(dev.aerox, 0, 1, 0.01, dev.aeroy, 0, 1, 0.1, exp_time=0.01,
|
||||
trigger_source="ddg", settle_time=0.15)
|
||||
```
|
||||
|
||||
- fast axis X moves continuously at `fast_step_size / exp_time`, the step axis Y steps between
|
||||
lines; the lines always run as a snake (alternating direction)
|
||||
- line start triggered by position: the PSO output rises on entering the window, at
|
||||
`trigger_low = fast_start - v * shutter_delay` on forward lines and at
|
||||
`trigger_high = exposure_end + v * shutter_delay` on backward lines, so in both directions
|
||||
the first exposure starts one shutter delay after the trigger at the first point of the
|
||||
line. The window is open on the far side (`[trigger_low, beyond line_end]` forward,
|
||||
`[beyond line_start, trigger_high]` backward): the axis never leaves it during a line.
|
||||
With a closed window the exit coincides with the end of the exposures and encoder noise
|
||||
at the exit edge started extra lines (2026-10-29, PandA: 2 extra edges in 11 lines).
|
||||
Armed once in pre_scan (one position reference); before each line only the range is
|
||||
switched (`PSOWINDOW RANGE`, no reload), with the axis outside both windows
|
||||
- hardware triggered scan: MCS card and detectors are armed once at stage for all
|
||||
lines x frames (num_points = lines, frames_per_trigger = frames per line); per line the
|
||||
scan only moves X and Y
|
||||
- `trigger_source`: `"ddg"` (beamline: PSO -> DDG1 EXT, one DDG1 burst of frames_per_line
|
||||
gates per trigger; DDG1/DDG2 take exp_time and frames_per_trigger from the scan info at
|
||||
stage; after each line the scan checks that the MCS reached the line's last channel),
|
||||
`"panda"` (test setup: PandA configured and armed once, every line checked with
|
||||
`wait_lines_done`; first gate one shutter-to-open delay after the edge, device config
|
||||
`shutter_delay`, default 8 ms as DDG1) or `None` (no trigger hardware, motion
|
||||
tests)
|
||||
- readout time (gap between the detector gates) from the trigger hardware, no scan
|
||||
argument: DDG2 `effective_readout_times()["ab"]` (default 0.2 ms) or the PandA device
|
||||
config `readout_time` (default 0.2 ms). The trigger positions move inward by
|
||||
`v * readout_time / 2`, so every gate is centred in its cell of `fast_step_size` and
|
||||
forward and backward lines have the same exposure centres (without it they differ by
|
||||
`fast_step_size * readout_time / exp_time`, measured 2026-10-29)
|
||||
- `trigger_edge` (DDG1): `"rising"` (default) for the opto output (J7-6/7, the tested
|
||||
wiring), `"falling"` for the active low open collector (J7-8) or differential (J7-1/2)
|
||||
output (not tested). The PandA has the same choice as device config `trigger_edge`
|
||||
(default `"rising"`, opto wiring).
|
||||
- run-up / run-out = twice the ramp distance `v^2 / (2 * acceleration)` + `v * settle_time`
|
||||
(default 0.15 s, measured 2026-10-28: the following error needs ~100-200 ms after the
|
||||
ramp at 1 mm/s / 10 mm/s^2). In a snake the run-out of a line is the run-up of the next.
|
||||
- at the turnarounds X does not wait for In Position (`wait_in_position` signal off during
|
||||
the scan); X reaches 1 um within ~60 ms but 10 nm only after 0.5-2 s
|
||||
- positions are always captured: device `aerocapture` (must be enabled) latches the
|
||||
encoder counters on each detector trigger (DATAACQ on UINT1, up to 16 kHz, 4 kHz with
|
||||
MXR sources). The Npaq input is inverted compared with the A3200 help (measured
|
||||
2026-10-29): DATAACQ trigger 3 latches at the rising TTL edge at J12 (gate start, the
|
||||
default), trigger 0 at the falling one (gate end); after each line they are read back (~2600 values/s over the ASCII interface)
|
||||
and published as async data, both as captured (`aerocapture_positions_enc<channel>`, start
|
||||
of each exposure) and as exposure centres (`aerocapture_positions_enc<channel>_centre`,
|
||||
in the user coordinates of the BEC motor of the channel, i.e. its `sign` applied,
|
||||
+ `v * gate_width / 2` in the direction of motion for the moving fast axis). The counter
|
||||
offset is calibrated in pre_scan with one software PSO fire, before any trigger receiver
|
||||
is armed. At the end the scan sends statistics to the client: centre - nominal cell
|
||||
centre per direction, backward - forward offset with slope and peak-peak along the line,
|
||||
scan and line times.
|
||||
- snake symmetry measured 2026-10-29 (PandA, 0.2 ms readout, before the readout
|
||||
compensation): at 0.5 and 2 mm/s no trend along the line, backward - forward constant
|
||||
within 3 um; at 20 mm/s with 1000 mm/s^2 +24 um with a trend of 5 um/mm, with 500 mm/s^2
|
||||
+4.7 um (+4 um of it the readout gap) -> aerox acceleration 500 mm/s^2
|
||||
|
||||
Soft test 2026-10-28 (scan sequence on the hardware, trigger_source=None, 1x1 mm, X 1 mm/s,
|
||||
11 lines): 15.3 s, line 1.11 s, Y step 0.28 s; PSO "inside window" switched on at X = 0.25
|
||||
going forward and at X = 0.76 going backward (window 0.25..0.76).
|
||||
|
||||
PSO output polarity: the PSO status bit "Output 1" is high outside the window. Open collector
|
||||
(J7-8) and differential outputs are active low (manual, not tested); the opto output
|
||||
(J7-6/7, JP16 normally open) conducts inside the window. Measured 2026-10-29 with the opto
|
||||
output sourcing from J7-25 (+5 V -> J7-6, J7-7 -> signal, J7-24 -> ground) into the 50 Ohm
|
||||
TTLIN3: high inside the window in both directions, i.e. a rising edge at the line start.
|
||||
The DDG1 EXT input is 50 Ohm as well, so the same wiring with `trigger_edge="rising"`.
|
||||
|
||||
Second client on the ASCII interface: only `~STATUS` (server command) is safe in parallel.
|
||||
Controller commands of all clients run on the library task by default and are refused
|
||||
while another client's command is executing ("An immediate command cannot be executed while
|
||||
another immediate command is executing"); a second client that sends controller commands
|
||||
must switch to its own task first (`~TASK 3`, tasks 1-4 are idle).
|
||||
|
||||
Wiring:
|
||||
|
||||
| Signal | From | To |
|
||||
|--------|------|----|
|
||||
| line trigger | Npaq J7 "PSO INTERFACE" (rear, User I/O, 25-pin D-sub), opto output (tested): J7-25 (+5 V) -> J7-6, J7-7 -> signal, J7-24 -> ground; rising edge. Alternatives (not tested, falling edge): pin 8 open collector + pin 24 common, pins 1/2 RS-422 | DDG1 EXT (50 Ohm) |
|
||||
| exposure trigger | detector trigger (DDG2 AB) | Npaq UINT1 (J12 pins 1+/2-, 5 V opto, 5-15 mA) |
|
||||
|
||||
Commissioning of the scan:
|
||||
|
||||
1. Channels (`pso_input`, `pso_counts_divider`, `aerocapture.channels`, see the table
|
||||
above); check that the counter direction and scale match the axis (calibration only
|
||||
fixes the offset).
|
||||
2. PSO output: select the output used (`PSOOUTPUT CONTROL`), polarity (JP16, opto output
|
||||
only; open collector and differential are active low). The PSO interlock is defeated
|
||||
(`IOSetup` 0x80).
|
||||
3. `acceleration` of the fast axis = the ramp rate BEC sets before each move.
|
||||
4. `max_scan_velocity` of the fast axis is the maximum scan velocity (default: `velocity`,
|
||||
the move velocity, which the scan restores afterwards).
|
||||
5. `GlobalDoubles` >= 255 (SAXS-TT: 255): BEC uses `$global[0..199]` for array reads and
|
||||
`$global[254]` as scratch variable. Another client must not use them during a scan.
|
||||
6. Check one line with the scope: PSO edge at the trigger position, number of UINT1 edges
|
||||
= frames per line, captured positions vs. requested positions.
|
||||
|
||||
## Test setup without beamline timing: PandABox line trigger
|
||||
|
||||
Config `device_configs/sastt_aerotech_panda.yaml` = `sastt_aerotech.yaml` + device
|
||||
`pandatrig` (`devices/panda_box/panda_line_trigger.py`, PandA `omny-panda.psi.ch`). The PandA
|
||||
replaces DDG1/DDG2 and behaves like them: it stays armed, and every line trigger edge sends a
|
||||
shutter pulse and n detector gates (8 ns resolution). A scan configures and arms once; the
|
||||
lines run back to back without re-arming.
|
||||
|
||||
| PandA | Use | Wiring |
|
||||
|-------|-----|--------|
|
||||
| TTLIN3 (50 Ω) | line trigger | Npaq J7 opto output: J7-25 (+5 V) -> J7-6, J7-7 -> TTLIN3, J7-24 -> TTLIN3 ground |
|
||||
| TTLOUT2 | "shutter": trigger edge to end of the last gate | scope |
|
||||
| TTLOUT3 | "detector" gates (high during exposure): n pulses, width exp_time - readout_time, period exp_time, first one shutter_delay after the edge | scope and Npaq J12 pin 1 (+) / pin 2 (-) for position sampling (rising edge = exposure start, DATAACQ trigger 3) |
|
||||
|
||||
Blocks: LUT8 = TTLIN3 | BITS.A (software trigger); SRGATE1 arm gate; LUT7 = rising edge of
|
||||
LUT8 & SRGATE1 & no line running (PULSE3/PULSE4 low) -> PULSE3 (shutter), PULSE4 (gates).
|
||||
An edge during a running line is ignored, like on the DDG. COUNTER6 counts raw edges,
|
||||
COUNTER7 accepted lines, COUNTER8 gates. Design `SASTT_line_trigger` on the PandA web
|
||||
interface, copy in `devices/panda_box/layouts/sastt_line_trigger.ini`; the device also writes
|
||||
the wiring at connect.
|
||||
|
||||
```python
|
||||
dev.pandatrig.configure_line(n_pulses=100, exp_time=0.01, readout_time=0.002, shutter_delay=0.005)
|
||||
dev.pandatrig.arm() # arm(one_shot=True): only the next edge
|
||||
dev.pandatrig.soft_line_trigger() # or the PSO edges on TTLIN3, one per line
|
||||
dev.pandatrig.wait_lines_done(n_lines=1, timeout=5)
|
||||
dev.pandatrig.line_status() # triggers, lines, pulses, dropped since arm()
|
||||
```
|
||||
|
||||
`wait_lines_done` fails on timeout and on a wrong number of lines or gates; ignored edges
|
||||
(during a running line, e.g. encoder noise at the PSO window entry) are only logged.
|
||||
|
||||
PandA FPGA 4.1 PULSE block: an ENABLE edge, a parameter write or a dropped trigger often
|
||||
makes it replay the previous pulse train without a trigger. The device keeps the PULSE
|
||||
blocks enabled, writes their parameters only when they change (once per scan) and then waits
|
||||
until such a train is over (pulses can appear on TTLOUT2/3 meanwhile; the train starts only
|
||||
~0.3-0.6 s after the write, so both outputs must stay low for 1 s), and the trigger path
|
||||
filters out edges during a line, so no trigger is ever dropped by a PULSE block.
|
||||
@@ -0,0 +1,881 @@
|
||||
OUTENC1.DATA.DELAY=0
|
||||
OUTENC2.DATA.DELAY=0
|
||||
OUTENC3.DATA.DELAY=0
|
||||
OUTENC4.DATA.DELAY=0
|
||||
OUTENC1.A.DELAY=0
|
||||
OUTENC2.A.DELAY=0
|
||||
OUTENC3.A.DELAY=0
|
||||
OUTENC4.A.DELAY=0
|
||||
OUTENC1.QPERIOD.UNITS=s
|
||||
OUTENC2.QPERIOD.UNITS=s
|
||||
OUTENC3.QPERIOD.UNITS=s
|
||||
OUTENC4.QPERIOD.UNITS=s
|
||||
OUTENC1.Z.DELAY=0
|
||||
OUTENC2.Z.DELAY=0
|
||||
OUTENC3.Z.DELAY=0
|
||||
OUTENC4.Z.DELAY=0
|
||||
OUTENC1.B.DELAY=0
|
||||
OUTENC2.B.DELAY=0
|
||||
OUTENC3.B.DELAY=0
|
||||
OUTENC4.B.DELAY=0
|
||||
OUTENC1.ENABLE.DELAY=0
|
||||
OUTENC2.ENABLE.DELAY=0
|
||||
OUTENC3.ENABLE.DELAY=0
|
||||
OUTENC4.ENABLE.DELAY=0
|
||||
PCOMP1.ENABLE.DELAY=0
|
||||
PCOMP2.ENABLE.DELAY=0
|
||||
TTLOUT1.VAL.DELAY=0
|
||||
TTLOUT2.VAL.DELAY=0
|
||||
TTLOUT3.VAL.DELAY=0
|
||||
TTLOUT4.VAL.DELAY=0
|
||||
TTLOUT5.VAL.DELAY=0
|
||||
TTLOUT6.VAL.DELAY=0
|
||||
TTLOUT7.VAL.DELAY=0
|
||||
TTLOUT8.VAL.DELAY=0
|
||||
TTLOUT9.VAL.DELAY=0
|
||||
TTLOUT10.VAL.DELAY=0
|
||||
PCAP.TS_END.CAPTURE=No
|
||||
PCAP.GATE.DELAY=1
|
||||
PCAP.TS_START.CAPTURE=No
|
||||
PCAP.ENABLE.DELAY=0
|
||||
PCAP.BITS2.CAPTURE=No
|
||||
PCAP.BITS0.CAPTURE=Value
|
||||
PCAP.BITS3.CAPTURE=No
|
||||
PCAP.BITS1.CAPTURE=No
|
||||
PCAP.GATE_DURATION.CAPTURE=Value
|
||||
PCAP.TS_TRIG.CAPTURE=No
|
||||
PCAP.TRIG.DELAY=1
|
||||
SFP3_SYNC_IN.POS3.UNITS=
|
||||
SFP3_SYNC_IN.POS3.OFFSET=0
|
||||
SFP3_SYNC_IN.POS3.SCALE=1
|
||||
SFP3_SYNC_IN.POS3.CAPTURE=No
|
||||
SFP3_SYNC_IN.POS4.UNITS=
|
||||
SFP3_SYNC_IN.POS4.OFFSET=0
|
||||
SFP3_SYNC_IN.POS4.SCALE=1
|
||||
SFP3_SYNC_IN.POS4.CAPTURE=No
|
||||
SFP3_SYNC_IN.POS2.UNITS=
|
||||
SFP3_SYNC_IN.POS2.OFFSET=0
|
||||
SFP3_SYNC_IN.POS2.SCALE=1
|
||||
SFP3_SYNC_IN.POS2.CAPTURE=No
|
||||
SFP3_SYNC_IN.POS1.UNITS=
|
||||
SFP3_SYNC_IN.POS1.OFFSET=0
|
||||
SFP3_SYNC_IN.POS1.SCALE=1
|
||||
SFP3_SYNC_IN.POS1.CAPTURE=No
|
||||
DIV1.INP.DELAY=0
|
||||
DIV2.INP.DELAY=0
|
||||
DIV1.ENABLE.DELAY=0
|
||||
DIV2.ENABLE.DELAY=0
|
||||
INENC1.CLK.DELAY=0
|
||||
INENC2.CLK.DELAY=0
|
||||
INENC3.CLK.DELAY=0
|
||||
INENC4.CLK.DELAY=0
|
||||
INENC1.VAL.UNITS=
|
||||
INENC2.VAL.UNITS=
|
||||
INENC3.VAL.UNITS=
|
||||
INENC4.VAL.UNITS=
|
||||
INENC1.VAL.OFFSET=0
|
||||
INENC2.VAL.OFFSET=0
|
||||
INENC3.VAL.OFFSET=0
|
||||
INENC4.VAL.OFFSET=0
|
||||
INENC1.VAL.SCALE=1
|
||||
INENC2.VAL.SCALE=1
|
||||
INENC3.VAL.SCALE=1
|
||||
INENC4.VAL.SCALE=1
|
||||
INENC1.VAL.CAPTURE=Value
|
||||
INENC2.VAL.CAPTURE=Value
|
||||
INENC3.VAL.CAPTURE=Value
|
||||
INENC4.VAL.CAPTURE=Value
|
||||
INENC1.CLK_PERIOD.UNITS=s
|
||||
INENC2.CLK_PERIOD.UNITS=s
|
||||
INENC3.CLK_PERIOD.UNITS=s
|
||||
INENC4.CLK_PERIOD.UNITS=s
|
||||
INENC1.FRAME_PERIOD.UNITS=s
|
||||
INENC2.FRAME_PERIOD.UNITS=s
|
||||
INENC3.FRAME_PERIOD.UNITS=s
|
||||
INENC4.FRAME_PERIOD.UNITS=s
|
||||
SFP3_SYNC_OUT.BIT8.DELAY=0
|
||||
SFP3_SYNC_OUT.BIT1.DELAY=0
|
||||
SFP3_SYNC_OUT.BIT3.DELAY=0
|
||||
SFP3_SYNC_OUT.BIT2.DELAY=0
|
||||
SFP3_SYNC_OUT.BIT5.DELAY=0
|
||||
SFP3_SYNC_OUT.BIT4.DELAY=0
|
||||
SFP3_SYNC_OUT.BIT7.DELAY=0
|
||||
SFP3_SYNC_OUT.BIT6.DELAY=0
|
||||
SEQ1.ENABLE.DELAY=0
|
||||
SEQ2.ENABLE.DELAY=0
|
||||
SEQ1.PRESCALE.UNITS=s
|
||||
SEQ2.PRESCALE.UNITS=s
|
||||
SEQ1.BITA.DELAY=0
|
||||
SEQ2.BITA.DELAY=0
|
||||
SEQ1.BITC.DELAY=0
|
||||
SEQ2.BITC.DELAY=0
|
||||
SEQ1.BITB.DELAY=0
|
||||
SEQ2.BITB.DELAY=0
|
||||
SEQ1.TABLE.QUEUED_LINES=0
|
||||
SEQ2.TABLE.QUEUED_LINES=0
|
||||
SRGATE1.SET.DELAY=0
|
||||
SRGATE2.SET.DELAY=0
|
||||
SRGATE3.SET.DELAY=0
|
||||
SRGATE4.SET.DELAY=0
|
||||
SRGATE1.ENABLE.DELAY=0
|
||||
SRGATE2.ENABLE.DELAY=0
|
||||
SRGATE3.ENABLE.DELAY=0
|
||||
SRGATE4.ENABLE.DELAY=0
|
||||
SRGATE1.RST.DELAY=0
|
||||
SRGATE2.RST.DELAY=0
|
||||
SRGATE3.RST.DELAY=0
|
||||
SRGATE4.RST.DELAY=0
|
||||
LUT1.INPB.DELAY=0
|
||||
LUT2.INPB.DELAY=0
|
||||
LUT3.INPB.DELAY=0
|
||||
LUT4.INPB.DELAY=0
|
||||
LUT5.INPB.DELAY=0
|
||||
LUT6.INPB.DELAY=0
|
||||
LUT7.INPB.DELAY=0
|
||||
LUT8.INPB.DELAY=0
|
||||
LUT1.INPC.DELAY=0
|
||||
LUT2.INPC.DELAY=0
|
||||
LUT3.INPC.DELAY=0
|
||||
LUT4.INPC.DELAY=0
|
||||
LUT5.INPC.DELAY=0
|
||||
LUT6.INPC.DELAY=0
|
||||
LUT7.INPC.DELAY=0
|
||||
LUT8.INPC.DELAY=0
|
||||
LUT1.INPA.DELAY=0
|
||||
LUT2.INPA.DELAY=0
|
||||
LUT3.INPA.DELAY=0
|
||||
LUT4.INPA.DELAY=0
|
||||
LUT5.INPA.DELAY=0
|
||||
LUT6.INPA.DELAY=0
|
||||
LUT7.INPA.DELAY=0
|
||||
LUT8.INPA.DELAY=0
|
||||
LUT1.INPD.DELAY=0
|
||||
LUT2.INPD.DELAY=0
|
||||
LUT3.INPD.DELAY=0
|
||||
LUT4.INPD.DELAY=0
|
||||
LUT5.INPD.DELAY=0
|
||||
LUT6.INPD.DELAY=0
|
||||
LUT7.INPD.DELAY=0
|
||||
LUT8.INPD.DELAY=0
|
||||
LUT1.INPE.DELAY=0
|
||||
LUT2.INPE.DELAY=0
|
||||
LUT3.INPE.DELAY=0
|
||||
LUT4.INPE.DELAY=0
|
||||
LUT5.INPE.DELAY=0
|
||||
LUT6.INPE.DELAY=0
|
||||
LUT7.INPE.DELAY=0
|
||||
LUT8.INPE.DELAY=0
|
||||
CALC1.OUT.UNITS=
|
||||
CALC2.OUT.UNITS=
|
||||
CALC1.OUT.OFFSET=0
|
||||
CALC2.OUT.OFFSET=0
|
||||
CALC1.OUT.SCALE=1
|
||||
CALC2.OUT.SCALE=1
|
||||
CALC1.OUT.CAPTURE=No
|
||||
CALC2.OUT.CAPTURE=No
|
||||
LVDSOUT1.VAL.DELAY=0
|
||||
LVDSOUT2.VAL.DELAY=0
|
||||
CLOCK1.ENABLE.DELAY=0
|
||||
CLOCK2.ENABLE.DELAY=0
|
||||
CLOCK1.PERIOD.UNITS=s
|
||||
CLOCK2.PERIOD.UNITS=s
|
||||
CLOCK1.WIDTH.UNITS=s
|
||||
CLOCK2.WIDTH.UNITS=us
|
||||
COUNTER1.OUT.UNITS=
|
||||
COUNTER2.OUT.UNITS=
|
||||
COUNTER3.OUT.UNITS=
|
||||
COUNTER4.OUT.UNITS=
|
||||
COUNTER5.OUT.UNITS=
|
||||
COUNTER6.OUT.UNITS=
|
||||
COUNTER7.OUT.UNITS=
|
||||
COUNTER8.OUT.UNITS=
|
||||
COUNTER1.OUT.OFFSET=0
|
||||
COUNTER2.OUT.OFFSET=0
|
||||
COUNTER3.OUT.OFFSET=0
|
||||
COUNTER4.OUT.OFFSET=0
|
||||
COUNTER5.OUT.OFFSET=0
|
||||
COUNTER6.OUT.OFFSET=0
|
||||
COUNTER7.OUT.OFFSET=0
|
||||
COUNTER8.OUT.OFFSET=0
|
||||
COUNTER1.OUT.SCALE=1
|
||||
COUNTER2.OUT.SCALE=1
|
||||
COUNTER3.OUT.SCALE=1
|
||||
COUNTER4.OUT.SCALE=1
|
||||
COUNTER5.OUT.SCALE=1
|
||||
COUNTER6.OUT.SCALE=1
|
||||
COUNTER7.OUT.SCALE=1
|
||||
COUNTER8.OUT.SCALE=1
|
||||
COUNTER1.OUT.CAPTURE=Value
|
||||
COUNTER2.OUT.CAPTURE=No
|
||||
COUNTER3.OUT.CAPTURE=No
|
||||
COUNTER4.OUT.CAPTURE=No
|
||||
COUNTER5.OUT.CAPTURE=No
|
||||
COUNTER6.OUT.CAPTURE=No
|
||||
COUNTER7.OUT.CAPTURE=No
|
||||
COUNTER8.OUT.CAPTURE=No
|
||||
COUNTER1.DIR.DELAY=0
|
||||
COUNTER2.DIR.DELAY=0
|
||||
COUNTER3.DIR.DELAY=0
|
||||
COUNTER4.DIR.DELAY=0
|
||||
COUNTER5.DIR.DELAY=0
|
||||
COUNTER6.DIR.DELAY=0
|
||||
COUNTER7.DIR.DELAY=0
|
||||
COUNTER8.DIR.DELAY=0
|
||||
COUNTER1.ENABLE.DELAY=0
|
||||
COUNTER2.ENABLE.DELAY=0
|
||||
COUNTER3.ENABLE.DELAY=0
|
||||
COUNTER4.ENABLE.DELAY=0
|
||||
COUNTER5.ENABLE.DELAY=0
|
||||
COUNTER6.ENABLE.DELAY=0
|
||||
COUNTER7.ENABLE.DELAY=0
|
||||
COUNTER8.ENABLE.DELAY=0
|
||||
COUNTER1.TRIG.DELAY=0
|
||||
COUNTER2.TRIG.DELAY=0
|
||||
COUNTER3.TRIG.DELAY=0
|
||||
COUNTER4.TRIG.DELAY=0
|
||||
COUNTER5.TRIG.DELAY=0
|
||||
COUNTER6.TRIG.DELAY=0
|
||||
COUNTER7.TRIG.DELAY=0
|
||||
COUNTER8.TRIG.DELAY=0
|
||||
PULSE1.ENABLE.DELAY=0
|
||||
PULSE2.ENABLE.DELAY=0
|
||||
PULSE3.ENABLE.DELAY=0
|
||||
PULSE4.ENABLE.DELAY=0
|
||||
PULSE1.DELAY.UNITS=s
|
||||
PULSE2.DELAY.UNITS=s
|
||||
PULSE3.DELAY.UNITS=s
|
||||
PULSE4.DELAY.UNITS=s
|
||||
PULSE1.TRIG.DELAY=0
|
||||
PULSE2.TRIG.DELAY=0
|
||||
PULSE3.TRIG.DELAY=0
|
||||
PULSE4.TRIG.DELAY=0
|
||||
PULSE1.WIDTH.UNITS=s
|
||||
PULSE2.WIDTH.UNITS=s
|
||||
PULSE3.WIDTH.UNITS=s
|
||||
PULSE4.WIDTH.UNITS=s
|
||||
PULSE1.STEP.UNITS=s
|
||||
PULSE2.STEP.UNITS=s
|
||||
PULSE3.STEP.UNITS=s
|
||||
PULSE4.STEP.UNITS=s
|
||||
FILTER1.OUT.UNITS=
|
||||
FILTER2.OUT.UNITS=
|
||||
FILTER1.OUT.OFFSET=0
|
||||
FILTER2.OUT.OFFSET=0
|
||||
FILTER1.OUT.SCALE=1
|
||||
FILTER2.OUT.SCALE=1
|
||||
FILTER1.OUT.CAPTURE=No
|
||||
FILTER2.OUT.CAPTURE=No
|
||||
FILTER1.ENABLE.DELAY=0
|
||||
FILTER2.ENABLE.DELAY=0
|
||||
FILTER1.TRIG.DELAY=0
|
||||
FILTER2.TRIG.DELAY=0
|
||||
FMC_IN.VAL8.UNITS=V
|
||||
FMC_IN.VAL8.OFFSET=0
|
||||
FMC_IN.VAL8.SCALE=4.65661287e-09
|
||||
FMC_IN.VAL8.CAPTURE=No
|
||||
FMC_IN.VAL1.UNITS=V
|
||||
FMC_IN.VAL1.OFFSET=0
|
||||
FMC_IN.VAL1.SCALE=4.65661287e-09
|
||||
FMC_IN.VAL1.CAPTURE=Value
|
||||
FMC_IN.VAL3.UNITS=V
|
||||
FMC_IN.VAL3.OFFSET=0
|
||||
FMC_IN.VAL3.SCALE=4.65661287e-09
|
||||
FMC_IN.VAL3.CAPTURE=No
|
||||
FMC_IN.VAL2.UNITS=V
|
||||
FMC_IN.VAL2.OFFSET=0
|
||||
FMC_IN.VAL2.SCALE=4.65661287e-09
|
||||
FMC_IN.VAL2.CAPTURE=Value
|
||||
FMC_IN.VAL5.UNITS=V
|
||||
FMC_IN.VAL5.OFFSET=0
|
||||
FMC_IN.VAL5.SCALE=4.65661287e-09
|
||||
FMC_IN.VAL5.CAPTURE=No
|
||||
FMC_IN.VAL4.UNITS=V
|
||||
FMC_IN.VAL4.OFFSET=0
|
||||
FMC_IN.VAL4.SCALE=4.65661287e-09
|
||||
FMC_IN.VAL4.CAPTURE=No
|
||||
FMC_IN.VAL7.UNITS=V
|
||||
FMC_IN.VAL7.OFFSET=0
|
||||
FMC_IN.VAL7.SCALE=4.65661287e-09
|
||||
FMC_IN.VAL7.CAPTURE=No
|
||||
FMC_IN.VAL6.UNITS=V
|
||||
FMC_IN.VAL6.OFFSET=0
|
||||
FMC_IN.VAL6.SCALE=4.65661287e-09
|
||||
FMC_IN.VAL6.CAPTURE=No
|
||||
PGEN1.OUT.UNITS=
|
||||
PGEN2.OUT.UNITS=
|
||||
PGEN1.OUT.OFFSET=0
|
||||
PGEN2.OUT.OFFSET=0
|
||||
PGEN1.OUT.SCALE=1
|
||||
PGEN2.OUT.SCALE=1
|
||||
PGEN1.OUT.CAPTURE=No
|
||||
PGEN2.OUT.CAPTURE=No
|
||||
PGEN1.ENABLE.DELAY=0
|
||||
PGEN2.ENABLE.DELAY=0
|
||||
PGEN1.TABLE.QUEUED_LINES=0
|
||||
PGEN2.TABLE.QUEUED_LINES=0
|
||||
PGEN1.TRIG.DELAY=0
|
||||
PGEN2.TRIG.DELAY=0
|
||||
TTLIN1.TERM=High-Z
|
||||
TTLIN2.TERM=High-Z
|
||||
TTLIN3.TERM=50-Ohm
|
||||
TTLIN4.TERM=High-Z
|
||||
TTLIN5.TERM=High-Z
|
||||
TTLIN6.TERM=High-Z
|
||||
OUTENC1.DATA=ZERO
|
||||
OUTENC2.DATA=ZERO
|
||||
OUTENC3.DATA=ZERO
|
||||
OUTENC4.DATA=ZERO
|
||||
OUTENC1.A=ZERO
|
||||
OUTENC2.A=ZERO
|
||||
OUTENC3.A=ZERO
|
||||
OUTENC4.A=ZERO
|
||||
OUTENC1.ENCODING=Unsigned Binary
|
||||
OUTENC2.ENCODING=Unsigned Binary
|
||||
OUTENC3.ENCODING=Unsigned Binary
|
||||
OUTENC4.ENCODING=Unsigned Binary
|
||||
OUTENC1.QPERIOD=0
|
||||
OUTENC2.QPERIOD=0
|
||||
OUTENC3.QPERIOD=0
|
||||
OUTENC4.QPERIOD=0
|
||||
OUTENC1.Z=ZERO
|
||||
OUTENC2.Z=ZERO
|
||||
OUTENC3.Z=ZERO
|
||||
OUTENC4.Z=ZERO
|
||||
OUTENC1.B=ZERO
|
||||
OUTENC2.B=ZERO
|
||||
OUTENC3.B=ZERO
|
||||
OUTENC4.B=ZERO
|
||||
OUTENC1.VAL=ZERO
|
||||
OUTENC2.VAL=ZERO
|
||||
OUTENC3.VAL=ZERO
|
||||
OUTENC4.VAL=ZERO
|
||||
OUTENC1.PROTOCOL=Quadrature
|
||||
OUTENC2.PROTOCOL=Quadrature
|
||||
OUTENC3.PROTOCOL=Quadrature
|
||||
OUTENC4.PROTOCOL=Quadrature
|
||||
OUTENC1.ENABLE=ZERO
|
||||
OUTENC2.ENABLE=ZERO
|
||||
OUTENC3.ENABLE=ZERO
|
||||
OUTENC4.ENABLE=ZERO
|
||||
OUTENC1.GENERATOR_ERROR=No
|
||||
OUTENC2.GENERATOR_ERROR=No
|
||||
OUTENC3.GENERATOR_ERROR=No
|
||||
OUTENC4.GENERATOR_ERROR=No
|
||||
OUTENC1.BITS=0
|
||||
OUTENC2.BITS=0
|
||||
OUTENC3.BITS=0
|
||||
OUTENC4.BITS=0
|
||||
PCOMP1.PULSES=0
|
||||
PCOMP2.PULSES=0
|
||||
PCOMP1.PRE_START=0
|
||||
PCOMP2.PRE_START=0
|
||||
PCOMP1.ENABLE=ZERO
|
||||
PCOMP2.ENABLE=ZERO
|
||||
PCOMP1.DIR=Positive
|
||||
PCOMP2.DIR=Positive
|
||||
PCOMP1.START=0
|
||||
PCOMP2.START=0
|
||||
PCOMP1.WIDTH=0
|
||||
PCOMP2.WIDTH=0
|
||||
PCOMP1.INP=ZERO
|
||||
PCOMP2.INP=ZERO
|
||||
PCOMP1.RELATIVE=Absolute
|
||||
PCOMP2.RELATIVE=Absolute
|
||||
PCOMP1.STEP=0
|
||||
PCOMP2.STEP=0
|
||||
TTLOUT1.FINE_DELAY=0
|
||||
TTLOUT2.FINE_DELAY=0
|
||||
TTLOUT3.FINE_DELAY=0
|
||||
TTLOUT4.FINE_DELAY=0
|
||||
TTLOUT5.FINE_DELAY=0
|
||||
TTLOUT6.FINE_DELAY=0
|
||||
TTLOUT7.FINE_DELAY=0
|
||||
TTLOUT8.FINE_DELAY=0
|
||||
TTLOUT9.FINE_DELAY=0
|
||||
TTLOUT10.FINE_DELAY=0
|
||||
TTLOUT1.QUARTER_DELAY=0
|
||||
TTLOUT2.QUARTER_DELAY=0
|
||||
TTLOUT3.QUARTER_DELAY=0
|
||||
TTLOUT4.QUARTER_DELAY=0
|
||||
TTLOUT5.QUARTER_DELAY=0
|
||||
TTLOUT6.QUARTER_DELAY=0
|
||||
TTLOUT7.QUARTER_DELAY=0
|
||||
TTLOUT8.QUARTER_DELAY=0
|
||||
TTLOUT9.QUARTER_DELAY=0
|
||||
TTLOUT10.QUARTER_DELAY=0
|
||||
TTLOUT1.VAL=ZERO
|
||||
TTLOUT2.VAL=PULSE3.OUT
|
||||
TTLOUT3.VAL=PULSE4.OUT
|
||||
TTLOUT4.VAL=ZERO
|
||||
TTLOUT5.VAL=ZERO
|
||||
TTLOUT6.VAL=ZERO
|
||||
TTLOUT7.VAL=ZERO
|
||||
TTLOUT8.VAL=ZERO
|
||||
TTLOUT9.VAL=ZERO
|
||||
TTLOUT10.VAL=ZERO
|
||||
PCAP.GATE=ZERO
|
||||
PCAP.ENABLE=ZERO
|
||||
PCAP.SHIFT_SUM=0
|
||||
PCAP.TRIG_EDGE=Falling
|
||||
PCAP.TRIG=ZERO
|
||||
DIV1.DIVISOR=0
|
||||
DIV2.DIVISOR=0
|
||||
DIV1.FIRST_PULSE=OutN
|
||||
DIV2.FIRST_PULSE=OutN
|
||||
DIV1.INP=ZERO
|
||||
DIV2.INP=ZERO
|
||||
DIV1.ENABLE=ZERO
|
||||
DIV2.ENABLE=ZERO
|
||||
INENC1.ENCODING=Unsigned Binary
|
||||
INENC2.ENCODING=Unsigned Binary
|
||||
INENC3.ENCODING=Unsigned Binary
|
||||
INENC4.ENCODING=Unsigned Binary
|
||||
INENC1.CLK=ZERO
|
||||
INENC2.CLK=ZERO
|
||||
INENC3.CLK=ZERO
|
||||
INENC4.CLK=ZERO
|
||||
INENC1.MSB_DISCARD=0
|
||||
INENC2.MSB_DISCARD=0
|
||||
INENC3.MSB_DISCARD=0
|
||||
INENC4.MSB_DISCARD=0
|
||||
INENC1.LSB_DISCARD=0
|
||||
INENC2.LSB_DISCARD=0
|
||||
INENC3.LSB_DISCARD=0
|
||||
INENC4.LSB_DISCARD=0
|
||||
INENC1.CLK_SRC=Internally Generated
|
||||
INENC2.CLK_SRC=Internally Generated
|
||||
INENC3.CLK_SRC=Internally Generated
|
||||
INENC4.CLK_SRC=Internally Generated
|
||||
INENC1.PROTOCOL=ZMI
|
||||
INENC2.PROTOCOL=ZMI
|
||||
INENC3.PROTOCOL=ZMI
|
||||
INENC4.PROTOCOL=ZMI
|
||||
INENC1.RST_ON_Z=0
|
||||
INENC2.RST_ON_Z=0
|
||||
INENC3.RST_ON_Z=0
|
||||
INENC4.RST_ON_Z=0
|
||||
INENC1.CLK_PERIOD=0.2
|
||||
INENC2.CLK_PERIOD=0
|
||||
INENC3.CLK_PERIOD=0
|
||||
INENC4.CLK_PERIOD=0
|
||||
INENC1.BITS=0
|
||||
INENC2.BITS=0
|
||||
INENC3.BITS=0
|
||||
INENC4.BITS=0
|
||||
INENC1.FRAME_PERIOD=0.2
|
||||
INENC2.FRAME_PERIOD=0
|
||||
INENC3.FRAME_PERIOD=0
|
||||
INENC4.FRAME_PERIOD=0
|
||||
BITS.A=0
|
||||
BITS.C=0
|
||||
BITS.D=0
|
||||
BITS.B=0
|
||||
SFP3_SYNC_OUT.POS4=ZERO
|
||||
SFP3_SYNC_OUT.POS2=ZERO
|
||||
SFP3_SYNC_OUT.POS3=ZERO
|
||||
SFP3_SYNC_OUT.POS1=ZERO
|
||||
SFP3_SYNC_OUT.BIT8=ZERO
|
||||
SFP3_SYNC_OUT.BIT1=ZERO
|
||||
SFP3_SYNC_OUT.BIT3=ZERO
|
||||
SFP3_SYNC_OUT.BIT2=ZERO
|
||||
SFP3_SYNC_OUT.BIT5=ZERO
|
||||
SFP3_SYNC_OUT.BIT4=ZERO
|
||||
SFP3_SYNC_OUT.BIT7=ZERO
|
||||
SFP3_SYNC_OUT.BIT6=ZERO
|
||||
SEQ1.ENABLE=ZERO
|
||||
SEQ2.ENABLE=ZERO
|
||||
SEQ1.PRESCALE=0
|
||||
SEQ2.PRESCALE=0
|
||||
SEQ1.BITA=ZERO
|
||||
SEQ2.BITA=ZERO
|
||||
SEQ1.BITC=ZERO
|
||||
SEQ2.BITC=ZERO
|
||||
SEQ1.BITB=ZERO
|
||||
SEQ2.BITB=ZERO
|
||||
SEQ1.REPEATS=0
|
||||
SEQ2.REPEATS=0
|
||||
SEQ1.POSB=ZERO
|
||||
SEQ2.POSB=ZERO
|
||||
SEQ1.POSC=ZERO
|
||||
SEQ2.POSC=ZERO
|
||||
SEQ1.POSA=ZERO
|
||||
SEQ2.POSA=ZERO
|
||||
SYSTEM.CLOCK_SOURCE=int clock
|
||||
SYSTEM.TIMESTAMP_SOURCE=None
|
||||
SRGATE1.WHEN_DISABLED=Set output low
|
||||
SRGATE2.WHEN_DISABLED=Set output low
|
||||
SRGATE3.WHEN_DISABLED=Set output low
|
||||
SRGATE4.WHEN_DISABLED=Set output low
|
||||
SRGATE1.RST_EDGE=Rising
|
||||
SRGATE2.RST_EDGE=Rising
|
||||
SRGATE3.RST_EDGE=Rising
|
||||
SRGATE4.RST_EDGE=Rising
|
||||
SRGATE1.SET=ZERO
|
||||
SRGATE2.SET=ZERO
|
||||
SRGATE3.SET=ZERO
|
||||
SRGATE4.SET=ZERO
|
||||
SRGATE1.SET_EDGE=Rising
|
||||
SRGATE2.SET_EDGE=Rising
|
||||
SRGATE3.SET_EDGE=Rising
|
||||
SRGATE4.SET_EDGE=Rising
|
||||
SRGATE1.ENABLE=ONE
|
||||
SRGATE2.ENABLE=ZERO
|
||||
SRGATE3.ENABLE=ZERO
|
||||
SRGATE4.ENABLE=ZERO
|
||||
SRGATE1.RST=ZERO
|
||||
SRGATE2.RST=ZERO
|
||||
SRGATE3.RST=ZERO
|
||||
SRGATE4.RST=ZERO
|
||||
FMC_OUT.GAIN4=5V
|
||||
FMC_OUT.GAIN1=5V
|
||||
FMC_OUT.GAIN3=5V
|
||||
FMC_OUT.GAIN2=5V
|
||||
FMC_OUT.VAL1=ZERO
|
||||
FMC_OUT.VAL3=ZERO
|
||||
FMC_OUT.VAL2=ZERO
|
||||
FMC_OUT.VAL4=ZERO
|
||||
LUT1.INPB=ZERO
|
||||
LUT2.INPB=ZERO
|
||||
LUT3.INPB=ZERO
|
||||
LUT4.INPB=ZERO
|
||||
LUT5.INPB=ZERO
|
||||
LUT6.INPB=ZERO
|
||||
LUT7.INPB=SRGATE1.OUT
|
||||
LUT8.INPB=BITS.OUTA
|
||||
LUT1.INPC=ZERO
|
||||
LUT2.INPC=ZERO
|
||||
LUT3.INPC=ZERO
|
||||
LUT4.INPC=ZERO
|
||||
LUT5.INPC=ZERO
|
||||
LUT6.INPC=ZERO
|
||||
LUT7.INPC=PULSE3.OUT
|
||||
LUT8.INPC=ZERO
|
||||
LUT1.INPA=ZERO
|
||||
LUT2.INPA=ZERO
|
||||
LUT3.INPA=ZERO
|
||||
LUT4.INPA=ZERO
|
||||
LUT5.INPA=ZERO
|
||||
LUT6.INPA=ZERO
|
||||
LUT7.INPA=LUT8.OUT
|
||||
LUT8.INPA=TTLIN3.VAL
|
||||
LUT1.INPD=ZERO
|
||||
LUT2.INPD=ZERO
|
||||
LUT3.INPD=ZERO
|
||||
LUT4.INPD=ZERO
|
||||
LUT5.INPD=ZERO
|
||||
LUT6.INPD=ZERO
|
||||
LUT7.INPD=PULSE4.OUT
|
||||
LUT8.INPD=ZERO
|
||||
LUT1.INPE=ZERO
|
||||
LUT2.INPE=ZERO
|
||||
LUT3.INPE=ZERO
|
||||
LUT4.INPE=ZERO
|
||||
LUT5.INPE=ZERO
|
||||
LUT6.INPE=ZERO
|
||||
LUT7.INPE=ZERO
|
||||
LUT8.INPE=ZERO
|
||||
LUT1.FUNC=0x00000000
|
||||
LUT2.FUNC=0x00000000
|
||||
LUT3.FUNC=0x00000000
|
||||
LUT4.FUNC=0x00000000
|
||||
LUT5.FUNC=0x00000000
|
||||
LUT6.FUNC=0x00000000
|
||||
LUT7.FUNC=A&B&~C&~D
|
||||
LUT8.FUNC=A|B
|
||||
LUT1.TYPEA=Input-Level
|
||||
LUT2.TYPEA=Input-Level
|
||||
LUT3.TYPEA=Input-Level
|
||||
LUT4.TYPEA=Input-Level
|
||||
LUT5.TYPEA=Input-Level
|
||||
LUT6.TYPEA=Input-Level
|
||||
LUT7.TYPEA=Pulse-On-Rising-Edge
|
||||
LUT8.TYPEA=Input-Level
|
||||
LUT1.TYPEC=Input-Level
|
||||
LUT2.TYPEC=Input-Level
|
||||
LUT3.TYPEC=Input-Level
|
||||
LUT4.TYPEC=Input-Level
|
||||
LUT5.TYPEC=Input-Level
|
||||
LUT6.TYPEC=Input-Level
|
||||
LUT7.TYPEC=Input-Level
|
||||
LUT8.TYPEC=Input-Level
|
||||
LUT1.TYPEB=Input-Level
|
||||
LUT2.TYPEB=Input-Level
|
||||
LUT3.TYPEB=Input-Level
|
||||
LUT4.TYPEB=Input-Level
|
||||
LUT5.TYPEB=Input-Level
|
||||
LUT6.TYPEB=Input-Level
|
||||
LUT7.TYPEB=Input-Level
|
||||
LUT8.TYPEB=Input-Level
|
||||
LUT1.TYPEE=Input-Level
|
||||
LUT2.TYPEE=Input-Level
|
||||
LUT3.TYPEE=Input-Level
|
||||
LUT4.TYPEE=Input-Level
|
||||
LUT5.TYPEE=Input-Level
|
||||
LUT6.TYPEE=Input-Level
|
||||
LUT7.TYPEE=Input-Level
|
||||
LUT8.TYPEE=Input-Level
|
||||
LUT1.TYPED=Input-Level
|
||||
LUT2.TYPED=Input-Level
|
||||
LUT3.TYPED=Input-Level
|
||||
LUT4.TYPED=Input-Level
|
||||
LUT5.TYPED=Input-Level
|
||||
LUT6.TYPED=Input-Level
|
||||
LUT7.TYPED=Input-Level
|
||||
LUT8.TYPED=Input-Level
|
||||
CALC1.INPC=ZERO
|
||||
CALC2.INPC=ZERO
|
||||
CALC1.INPA=ZERO
|
||||
CALC2.INPA=ZERO
|
||||
CALC1.SHIFT=0
|
||||
CALC2.SHIFT=0
|
||||
CALC1.INPB=ZERO
|
||||
CALC2.INPB=ZERO
|
||||
CALC1.INPD=ZERO
|
||||
CALC2.INPD=ZERO
|
||||
CALC1.TYPEA=Value
|
||||
CALC2.TYPEA=Value
|
||||
CALC1.TYPEC=Value
|
||||
CALC2.TYPEC=Value
|
||||
CALC1.TYPEB=Value
|
||||
CALC2.TYPEB=Value
|
||||
CALC1.TYPED=Value
|
||||
CALC2.TYPED=Value
|
||||
LVDSOUT1.FINE_DELAY=0
|
||||
LVDSOUT2.FINE_DELAY=0
|
||||
LVDSOUT1.QUARTER_DELAY=0
|
||||
LVDSOUT2.QUARTER_DELAY=0
|
||||
LVDSOUT1.VAL=ZERO
|
||||
LVDSOUT2.VAL=ZERO
|
||||
CLOCK1.ENABLE=ZERO
|
||||
CLOCK2.ENABLE=ZERO
|
||||
CLOCK1.PERIOD=0.0001
|
||||
CLOCK2.PERIOD=0.2
|
||||
CLOCK1.WIDTH=3e-05
|
||||
CLOCK2.WIDTH=5
|
||||
COUNTER1.OUT_MODE=On-Change
|
||||
COUNTER2.OUT_MODE=On-Change
|
||||
COUNTER3.OUT_MODE=On-Change
|
||||
COUNTER4.OUT_MODE=On-Change
|
||||
COUNTER5.OUT_MODE=On-Change
|
||||
COUNTER6.OUT_MODE=On-Change
|
||||
COUNTER7.OUT_MODE=On-Change
|
||||
COUNTER8.OUT_MODE=On-Change
|
||||
COUNTER1.SET=0
|
||||
COUNTER2.SET=0
|
||||
COUNTER3.SET=0
|
||||
COUNTER4.SET=0
|
||||
COUNTER5.SET=0
|
||||
COUNTER6.SET=0
|
||||
COUNTER7.SET=0
|
||||
COUNTER8.SET=0
|
||||
COUNTER1.DIR=ZERO
|
||||
COUNTER2.DIR=ZERO
|
||||
COUNTER3.DIR=ZERO
|
||||
COUNTER4.DIR=ZERO
|
||||
COUNTER5.DIR=ZERO
|
||||
COUNTER6.DIR=ZERO
|
||||
COUNTER7.DIR=ZERO
|
||||
COUNTER8.DIR=ZERO
|
||||
COUNTER1.ENABLE=ZERO
|
||||
COUNTER2.ENABLE=ZERO
|
||||
COUNTER3.ENABLE=ZERO
|
||||
COUNTER4.ENABLE=ZERO
|
||||
COUNTER5.ENABLE=ZERO
|
||||
COUNTER6.ENABLE=ZERO
|
||||
COUNTER7.ENABLE=ZERO
|
||||
COUNTER8.ENABLE=ZERO
|
||||
COUNTER1.TRIG=ZERO
|
||||
COUNTER2.TRIG=ZERO
|
||||
COUNTER3.TRIG=ZERO
|
||||
COUNTER4.TRIG=ZERO
|
||||
COUNTER5.TRIG=ZERO
|
||||
COUNTER6.TRIG=LUT8.OUT
|
||||
COUNTER7.TRIG=LUT7.OUT
|
||||
COUNTER8.TRIG=PULSE4.OUT
|
||||
COUNTER1.TRIG_EDGE=Falling
|
||||
COUNTER2.TRIG_EDGE=Rising
|
||||
COUNTER3.TRIG_EDGE=Rising
|
||||
COUNTER4.TRIG_EDGE=Rising
|
||||
COUNTER5.TRIG_EDGE=Rising
|
||||
COUNTER6.TRIG_EDGE=Rising
|
||||
COUNTER7.TRIG_EDGE=Rising
|
||||
COUNTER8.TRIG_EDGE=Rising
|
||||
COUNTER1.MIN=0
|
||||
COUNTER2.MIN=0
|
||||
COUNTER3.MIN=0
|
||||
COUNTER4.MIN=0
|
||||
COUNTER5.MIN=0
|
||||
COUNTER6.MIN=0
|
||||
COUNTER7.MIN=0
|
||||
COUNTER8.MIN=0
|
||||
COUNTER1.MAX=0
|
||||
COUNTER2.MAX=0
|
||||
COUNTER3.MAX=0
|
||||
COUNTER4.MAX=0
|
||||
COUNTER5.MAX=0
|
||||
COUNTER6.MAX=0
|
||||
COUNTER7.MAX=0
|
||||
COUNTER8.MAX=0
|
||||
COUNTER1.START=0
|
||||
COUNTER2.START=0
|
||||
COUNTER3.START=0
|
||||
COUNTER4.START=0
|
||||
COUNTER5.START=0
|
||||
COUNTER6.START=0
|
||||
COUNTER7.START=0
|
||||
COUNTER8.START=0
|
||||
COUNTER1.STEP=1
|
||||
COUNTER2.STEP=1
|
||||
COUNTER3.STEP=0
|
||||
COUNTER4.STEP=0
|
||||
COUNTER5.STEP=0
|
||||
COUNTER6.STEP=1
|
||||
COUNTER7.STEP=1
|
||||
COUNTER8.STEP=1
|
||||
PULSE1.PULSES=0
|
||||
PULSE2.PULSES=5
|
||||
PULSE3.PULSES=1
|
||||
PULSE4.PULSES=5
|
||||
PULSE1.ENABLE=ONE
|
||||
PULSE2.ENABLE=ZERO
|
||||
PULSE3.ENABLE=ONE
|
||||
PULSE4.ENABLE=ONE
|
||||
PULSE1.DELAY=0
|
||||
PULSE2.DELAY=0
|
||||
PULSE3.DELAY=0
|
||||
PULSE4.DELAY=0
|
||||
PULSE1.TRIG=ZERO
|
||||
PULSE2.TRIG=ZERO
|
||||
PULSE3.TRIG=LUT7.OUT
|
||||
PULSE4.TRIG=LUT7.OUT
|
||||
PULSE1.TRIG_EDGE=Rising
|
||||
PULSE2.TRIG_EDGE=Rising
|
||||
PULSE3.TRIG_EDGE=Rising
|
||||
PULSE4.TRIG_EDGE=Rising
|
||||
PULSE1.WIDTH=0.001
|
||||
PULSE2.WIDTH=0.002
|
||||
PULSE3.WIDTH=0.01
|
||||
PULSE4.WIDTH=0.002
|
||||
PULSE1.STEP=0
|
||||
PULSE2.STEP=0.0025
|
||||
PULSE3.STEP=0
|
||||
PULSE4.STEP=0.002
|
||||
FILTER1.INP=ZERO
|
||||
FILTER2.INP=ZERO
|
||||
FILTER1.MODE=average
|
||||
FILTER2.MODE=difference
|
||||
FILTER1.ENABLE=ZERO
|
||||
FILTER2.ENABLE=ZERO
|
||||
FILTER1.TRIG=ZERO
|
||||
FILTER2.TRIG=ZERO
|
||||
FMC_IN.GAIN5=10V
|
||||
FMC_IN.GAIN4=10V
|
||||
FMC_IN.GAIN7=10V
|
||||
FMC_IN.GAIN6=10V
|
||||
FMC_IN.GAIN1=10V
|
||||
FMC_IN.GAIN3=10V
|
||||
FMC_IN.GAIN2=10V
|
||||
FMC_IN.GAIN8=10V
|
||||
PGEN1.REPEATS=0
|
||||
PGEN2.REPEATS=0
|
||||
PGEN1.ENABLE=ZERO
|
||||
PGEN2.ENABLE=ZERO
|
||||
PGEN1.TRIG=ZERO
|
||||
PGEN2.TRIG=ZERO
|
||||
*METADATA.LABEL_CALC1=
|
||||
*METADATA.LABEL_FILTER1=
|
||||
*METADATA.LABEL_CALC2=
|
||||
*METADATA.LABEL_SRGATE4=
|
||||
*METADATA.LABEL_FMC_IN1=
|
||||
*METADATA.LABEL_TTLIN6=
|
||||
*METADATA.LABEL_SRGATE1=
|
||||
*METADATA.LABEL_TTLIN4=
|
||||
*METADATA.LABEL_TTLIN5=
|
||||
*METADATA.LABEL_TTLIN2=TTL input 2
|
||||
*METADATA.LABEL_TTLIN3=
|
||||
*METADATA.LABEL_CLOCK1=
|
||||
*METADATA.LABEL_TTLIN1=TTL input 1
|
||||
*METADATA.LABEL_PCOMP1=
|
||||
*METADATA.LABEL_LUT8=
|
||||
*METADATA.LABEL_BITS1=Soft inputs and constant bits
|
||||
*METADATA.LABEL_COUNTER8=
|
||||
*METADATA.LABEL_COUNTER7=
|
||||
*METADATA.LABEL_COUNTER6=
|
||||
*METADATA.LABEL_COUNTER5=
|
||||
*METADATA.LABEL_COUNTER4=
|
||||
*METADATA.LABEL_COUNTER3=
|
||||
*METADATA.LABEL_COUNTER2=
|
||||
*METADATA.LABEL_COUNTER1=
|
||||
*METADATA.LABEL_TTLOUT3=TTL output 3
|
||||
*METADATA.LABEL_OUTENC2=
|
||||
*METADATA.LABEL_OUTENC3=
|
||||
*METADATA.LABEL_SYSTEM1=System control FPGA
|
||||
*METADATA.LABEL_OUTENC1=
|
||||
*METADATA.LABEL_PGEN2=
|
||||
*METADATA.LABEL_OUTENC4=
|
||||
*METADATA.LABEL_PGEN1=
|
||||
*METADATA.LABEL_LUT2=
|
||||
*METADATA.LABEL_LUT3=
|
||||
*METADATA.LABEL_LUT1=Lookup table 1
|
||||
*METADATA.LABEL_LUT6=
|
||||
*METADATA.LABEL_LUT7=
|
||||
*METADATA.LABEL_LUT4=
|
||||
*METADATA.LABEL_LUT5=
|
||||
*METADATA.LABEL_LVDSOUT2=
|
||||
*METADATA.LABEL_LVDSOUT1=
|
||||
*METADATA.LABEL_SFP3_SYNC_OUT1=
|
||||
*METADATA.LABEL_PULSE3=
|
||||
*METADATA.LABEL_TTLOUT7=
|
||||
*METADATA.LABEL_SRGATE2=
|
||||
*METADATA.LABEL_LVDSIN2=
|
||||
*METADATA.LABEL_LVDSIN1=
|
||||
*METADATA.LABEL_SEQ1=
|
||||
*METADATA.LABEL_CLOCK2=
|
||||
*METADATA.LABEL_PULSE4=
|
||||
*METADATA.LABEL_INENC1=
|
||||
*METADATA.LABEL_INENC2=
|
||||
*METADATA.LABEL_INENC3=
|
||||
*METADATA.LABEL_INENC4=
|
||||
*METADATA.LABEL_PULSE1=
|
||||
*METADATA.LABEL_PULSE2=
|
||||
*METADATA.LABEL_FILTER2=
|
||||
*METADATA.LABEL_TTLOUT10=
|
||||
*METADATA.LABEL_SEQ2=
|
||||
*METADATA.LABEL_PCOMP2=
|
||||
*METADATA.LABEL_DIV1=
|
||||
*METADATA.LABEL_SRGATE3=
|
||||
*METADATA.LABEL_FMC_OUT1=
|
||||
*METADATA.LABEL_DIV2=
|
||||
*METADATA.LABEL_SFP3_SYNC_IN1=
|
||||
*METADATA.LABEL_TTLOUT8=
|
||||
*METADATA.LABEL_TTLOUT9=
|
||||
*METADATA.LABEL_TTLOUT6=
|
||||
*METADATA.LABEL_PCAP1=Position capture control
|
||||
*METADATA.LABEL_TTLOUT4=TTL output 4
|
||||
*METADATA.LABEL_TTLOUT5=
|
||||
*METADATA.LABEL_TTLOUT2=TTL output 2
|
||||
*METADATA.DESIGN=
|
||||
*METADATA.LABEL_TTLOUT1=TTL output 1
|
||||
SEQ1.TABLE<B
|
||||
|
||||
SEQ2.TABLE<B
|
||||
|
||||
PGEN1.TABLE<B
|
||||
|
||||
PGEN2.TABLE<B
|
||||
|
||||
*METADATA.LAYOUT<
|
||||
{"BITS": {"x": -700.0, "y": 250.0},
|
||||
"COUNTER6": {"x": -150.0, "y": -200.0},
|
||||
"COUNTER7": {"x": 200.0, "y": 450.0},
|
||||
"COUNTER8": {"x": 550.0, "y": 400.0},
|
||||
"LUT7": {"x": -150.0, "y": 150.0},
|
||||
"LUT8": {"x": -450.0, "y": 80.0},
|
||||
"PULSE3": {"x": 200.0, "y": -100.0},
|
||||
"PULSE4": {"x": 200.0, "y": 200.0},
|
||||
"SRGATE1": {"x": -450.0, "y": 330.0},
|
||||
"TTLIN3": {"x": -700.0, "y": 0.0},
|
||||
"TTLOUT2": {"x": 550.0, "y": -100.0},
|
||||
"TTLOUT3": {"x": 550.0, "y": 150.0}}
|
||||
|
||||
*METADATA.EXPORTS<
|
||||
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
PandABox as a line trigger delay generator (stand-in for DDG1/DDG2 in test setups).
|
||||
|
||||
Like the DDG it stays armed: every line trigger edge (TTLIN3, e.g. the Aerotech PSO output)
|
||||
starts one line
|
||||
- PULSE3 -> TTLOUT2: "shutter", one pulse from the trigger to the end of the last exposure
|
||||
- PULSE4 -> TTLOUT3: "detector" gates, n pulses of width exp_time - readout_time every
|
||||
exp_time, the first one shutter_delay after the trigger
|
||||
so a scan arms once and the lines run back to back.
|
||||
|
||||
Trigger path:
|
||||
- LUT8 = TTLIN3 | BITS.A: raw line trigger, BITS.A is the software trigger
|
||||
- SRGATE1: arm gate, set by arm(); one_shot=True resets it with the first accepted edge
|
||||
- LUT7 = rising edge of LUT8 & SRGATE1 & ~PULSE3.OUT & ~PULSE4.OUT: an edge is accepted
|
||||
only while armed and while no line is running; edges during a line are ignored (the
|
||||
DDG ignores them too) and never reach the PULSE blocks
|
||||
Counters: COUNTER6 raw edges, COUNTER7 accepted lines, COUNTER8 detector pulses.
|
||||
|
||||
PULSE block problem (PandA FPGA 4.1, seen 2026-10-28): a rising edge of ENABLE, a parameter
|
||||
write or a dropped trigger often makes the block replay the previous pulse train without
|
||||
any trigger. With unchanged parameters and ENABLE kept high, hundreds of triggered lines in
|
||||
a row were correct. So the PULSE blocks stay enabled, their parameters are only written
|
||||
when they change (once per scan), followed by a wait until a replayed train is over, and
|
||||
edges during a line are filtered out before the PULSE blocks. Replayed trains during a
|
||||
parameter change do appear on TTLOUT2/3. The replayed train starts only ~0.3-1.3 s after
|
||||
the write (seen 2026-10-29), so configure_line waits until both PULSE outputs stayed low
|
||||
for PARAM_QUIET_TIME. All fields are read first and written only if the PandA holds a
|
||||
different value: a new device instance (BEC restart, reconnect) on an already configured
|
||||
PandA writes nothing, so it cannot cause a replay.
|
||||
|
||||
Only the control port (8888) is used: the fields are written by setup_layout() at connect,
|
||||
the PCAP data capture is not touched. Timing resolution 8 ns (125 MHz FPGA clock).
|
||||
Design SASTT_line_trigger on the PandA web interface, copy in layouts/sastt_line_trigger.ini.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from bec_lib.logger import bec_logger
|
||||
from ophyd import Device
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class PandaLineTriggerError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PandaControl:
|
||||
"""Minimal client of the PandABlocks control protocol (one line per command)."""
|
||||
|
||||
def __init__(self, host: str, port: int = 8888, timeout: float = 5.0):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self._sock = None
|
||||
self._buffer = b""
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def open(self) -> None:
|
||||
self._sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
||||
self._buffer = b""
|
||||
|
||||
def close(self) -> None:
|
||||
if self._sock is not None:
|
||||
self._sock.close()
|
||||
self._sock = None
|
||||
|
||||
def _readline(self) -> str:
|
||||
while b"\n" not in self._buffer:
|
||||
data = self._sock.recv(4096)
|
||||
if not data:
|
||||
raise PandaLineTriggerError(f"PandA {self.host} closed the connection.")
|
||||
self._buffer += data
|
||||
line, self._buffer = self._buffer.split(b"\n", 1)
|
||||
return line.decode().strip()
|
||||
|
||||
def send(self, command: str) -> str:
|
||||
"""Send one command, return the value of 'OK =<value>' ('' for 'OK')."""
|
||||
with self._lock:
|
||||
if self._sock is None:
|
||||
self.open()
|
||||
self._sock.sendall(command.encode() + b"\n")
|
||||
reply = self._readline()
|
||||
if reply.startswith("OK ="):
|
||||
return reply[4:]
|
||||
if reply == "OK":
|
||||
return ""
|
||||
raise PandaLineTriggerError(f"PandA {self.host}: '{command}' -> '{reply}'")
|
||||
|
||||
def get(self, field: str) -> str:
|
||||
return self.send(f"{field}?")
|
||||
|
||||
def set(self, field: str, value) -> None:
|
||||
self.send(f"{field}={value}")
|
||||
|
||||
|
||||
class PandaLineTrigger(Device):
|
||||
"""
|
||||
PandABox line trigger: while armed, every line trigger edge gives a shutter pulse and
|
||||
n detector gates.
|
||||
|
||||
Args:
|
||||
host: PandA host name.
|
||||
port: control port.
|
||||
input_termination: TTLIN3 termination, "50-Ohm" (pull-down for the Npaq opto
|
||||
output) or "High-Z".
|
||||
trigger_edge: edge of TTLIN3 that starts a line, "rising" (Npaq opto output) or
|
||||
"falling" (active low open collector / differential output).
|
||||
readout_time: detector readout time in s, gap between the gates (gate width =
|
||||
exp_time - readout_time). Default 0.2 ms, as the DDG2 default at the beamline.
|
||||
shutter_delay: time from the line trigger edge to the first gate, in s, for the
|
||||
shutter to open. Default 8 ms, as DDG1 at the beamline (it opens the shutter at
|
||||
the trigger and starts DDG2 this much later).
|
||||
"""
|
||||
|
||||
USER_ACCESS = [
|
||||
"setup_layout",
|
||||
"configure_line",
|
||||
"arm",
|
||||
"disarm",
|
||||
"soft_line_trigger",
|
||||
"line_status",
|
||||
"wait_lines_done",
|
||||
"get_readout_time",
|
||||
"get_shutter_to_open_delay",
|
||||
"set_shutter_to_open_delay",
|
||||
"panda",
|
||||
]
|
||||
|
||||
# same guard rail as DDG1
|
||||
MAX_SHUTTER_TO_OPEN_DELAY = 50e-3
|
||||
|
||||
TRIGGER_INPUT = "TTLIN3"
|
||||
SOFT_TRIGGER_BIT = "BITS.A"
|
||||
RAW_TRIGGER_LUT = "LUT8"
|
||||
ARM_GATE = "SRGATE1"
|
||||
ACCEPTED_TRIGGER_LUT = "LUT7"
|
||||
SHUTTER_PULSE = "PULSE3"
|
||||
DETECTOR_PULSE = "PULSE4"
|
||||
TRIGGER_COUNTER = "COUNTER6"
|
||||
LINE_COUNTER = "COUNTER7"
|
||||
PULSE_COUNTER = "COUNTER8"
|
||||
# a replayed train starts up to ~1.3 s after a parameter write
|
||||
PARAM_QUIET_TIME = 2.0
|
||||
SHUTTER_OUTPUT = "TTLOUT2"
|
||||
DETECTOR_OUTPUT = "TTLOUT3"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
prefix="",
|
||||
*,
|
||||
name,
|
||||
host: str,
|
||||
port: int = 8888,
|
||||
input_termination: str = "50-Ohm",
|
||||
trigger_edge: str = "rising",
|
||||
readout_time: float = 2e-4,
|
||||
shutter_delay: float = 8e-3,
|
||||
device_manager=None,
|
||||
**kwargs,
|
||||
):
|
||||
self.panda = PandaControl(host, port)
|
||||
self.readout_time = float(readout_time)
|
||||
self._shutter_to_open_delay = 0.0
|
||||
self.set_shutter_to_open_delay(shutter_delay)
|
||||
self.input_termination = input_termination
|
||||
if trigger_edge not in ("rising", "falling"):
|
||||
raise ValueError(f"trigger_edge must be 'rising' or 'falling', got {trigger_edge!r}.")
|
||||
self.trigger_edge = trigger_edge
|
||||
self._pulse_params = None # parameters written to the PULSE blocks
|
||||
self._dropped_at_arm = 0 # DROPPED is only zeroed by ENABLE, which is never toggled
|
||||
self._ignored_reported = 0 # ignored edges since arm() already logged
|
||||
self.device_manager = device_manager
|
||||
super().__init__(prefix, name=name, **kwargs)
|
||||
|
||||
def wait_for_connection(self, all_signals=False, timeout: float = 5.0) -> None:
|
||||
self.panda.open()
|
||||
idn = self.panda.send("*IDN?")
|
||||
logger.info(f"{self.name}: connected to {self.panda.host}: {idn}")
|
||||
self.setup_layout()
|
||||
|
||||
def destroy(self):
|
||||
self.panda.close()
|
||||
super().destroy()
|
||||
|
||||
def stop(self, *, success=False):
|
||||
try:
|
||||
self.disarm()
|
||||
except Exception as exc: # stop must not raise
|
||||
logger.warning(f"{self.name}: disarm on stop failed: {exc}")
|
||||
super().stop(success=success)
|
||||
|
||||
def _layout(self) -> list[tuple[str, str]]:
|
||||
raw, gate, accepted = self.RAW_TRIGGER_LUT, self.ARM_GATE, self.ACCEPTED_TRIGGER_LUT
|
||||
sp, dp = self.SHUTTER_PULSE, self.DETECTOR_PULSE
|
||||
fields = [
|
||||
(f"{self.TRIGGER_INPUT}.TERM", self.input_termination),
|
||||
(self.SOFT_TRIGGER_BIT, "0"),
|
||||
(f"{raw}.INPA", f"{self.TRIGGER_INPUT}.VAL"),
|
||||
(f"{raw}.INPB", "BITS.OUTA"),
|
||||
# raw trigger active high: a falling TTLIN3 edge is inverted to a rising one
|
||||
(f"{raw}.FUNC", "A|B" if self.trigger_edge == "rising" else "~A|B"),
|
||||
(f"{gate}.ENABLE", "ONE"),
|
||||
(f"{gate}.SET", "ZERO"),
|
||||
(f"{gate}.RST", "ZERO"),
|
||||
(f"{gate}.RST_EDGE", "Rising"),
|
||||
(f"{gate}.WHEN_DISABLED", "Set output low"),
|
||||
(f"{gate}.FORCE_RST", ""),
|
||||
(f"{accepted}.INPA", f"{raw}.OUT"),
|
||||
(f"{accepted}.TYPEA", "Pulse-On-Rising-Edge"),
|
||||
(f"{accepted}.INPB", f"{gate}.OUT"),
|
||||
(f"{accepted}.TYPEB", "Input-Level"),
|
||||
(f"{accepted}.INPC", f"{sp}.OUT"),
|
||||
(f"{accepted}.TYPEC", "Input-Level"),
|
||||
(f"{accepted}.INPD", f"{dp}.OUT"),
|
||||
(f"{accepted}.TYPED", "Input-Level"),
|
||||
(f"{accepted}.FUNC", "A&B&~C&~D"),
|
||||
]
|
||||
for pulse in (sp, dp):
|
||||
fields += [
|
||||
(f"{pulse}.ENABLE", "ONE"),
|
||||
(f"{pulse}.TRIG", f"{accepted}.OUT"),
|
||||
(f"{pulse}.TRIG_EDGE", "Rising"),
|
||||
(f"{pulse}.DELAY.UNITS", "s"),
|
||||
(f"{pulse}.WIDTH.UNITS", "s"),
|
||||
(f"{pulse}.STEP.UNITS", "s"),
|
||||
]
|
||||
for counter, source in (
|
||||
(self.TRIGGER_COUNTER, f"{raw}.OUT"),
|
||||
(self.LINE_COUNTER, f"{accepted}.OUT"),
|
||||
(self.PULSE_COUNTER, f"{dp}.OUT"),
|
||||
):
|
||||
fields += [
|
||||
(f"{counter}.ENABLE", "ZERO"),
|
||||
(f"{counter}.TRIG", source),
|
||||
(f"{counter}.TRIG_EDGE", "Rising"),
|
||||
(f"{counter}.DIR", "ZERO"),
|
||||
(f"{counter}.START", "0"),
|
||||
(f"{counter}.STEP", "1"),
|
||||
]
|
||||
fields += [
|
||||
(f"{self.SHUTTER_OUTPUT}.VAL", f"{sp}.OUT"),
|
||||
(f"{self.DETECTOR_OUTPUT}.VAL", f"{dp}.OUT"),
|
||||
]
|
||||
return fields
|
||||
|
||||
def get_readout_time(self) -> float:
|
||||
"""Readout time (gap between the detector gates) used by the scans, in s."""
|
||||
return self.readout_time
|
||||
|
||||
def get_shutter_to_open_delay(self) -> float:
|
||||
"""Time from the line trigger to the first gate used by the scans, in s (as DDG1)."""
|
||||
return self._shutter_to_open_delay
|
||||
|
||||
def set_shutter_to_open_delay(self, delay: float) -> None:
|
||||
"""Set the time from the line trigger to the first gate, in s (as DDG1)."""
|
||||
delay = float(delay)
|
||||
if not 0 <= delay <= self.MAX_SHUTTER_TO_OPEN_DELAY:
|
||||
raise ValueError(
|
||||
f"Shutter-to-open delay must be between 0 and {self.MAX_SHUTTER_TO_OPEN_DELAY}"
|
||||
f" s, got {delay}."
|
||||
)
|
||||
self._shutter_to_open_delay = delay
|
||||
|
||||
def setup_layout(self) -> None:
|
||||
"""Write the wiring of the line trigger blocks (other blocks are not touched)."""
|
||||
written = [field for field, value in self._layout() if self._set_if_changed(field, value)]
|
||||
self._pulse_params = None
|
||||
pulses = (self.SHUTTER_PULSE, self.DETECTOR_PULSE)
|
||||
if any(field.split(".")[0] in pulses for field in written):
|
||||
self._wait_pulses_idle(2 * self._train_duration(None) + 1)
|
||||
|
||||
def _set_if_changed(self, field: str, value) -> bool:
|
||||
"""
|
||||
Write a field only if the PandA holds a different value (fewer writes, fewer replayed
|
||||
PULSE trains). Actions (empty value, e.g. FORCE_RST) are always sent. Returns True
|
||||
if the field was written.
|
||||
"""
|
||||
value = str(value)
|
||||
if value != "":
|
||||
current = self.panda.get(field)
|
||||
try:
|
||||
if np.isclose(float(current), float(value), rtol=1e-9, atol=1e-12):
|
||||
return False
|
||||
except ValueError:
|
||||
if current == value:
|
||||
return False
|
||||
self.panda.set(field, value)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _train_duration(params: dict | None) -> float:
|
||||
if params is None:
|
||||
return 0.1
|
||||
return params["delay"] + params["n_pulses"] * params["step"]
|
||||
|
||||
def configure_line(
|
||||
self, n_pulses: int, exp_time: float, readout_time: float = 0.0, shutter_delay: float = 0.0
|
||||
) -> None:
|
||||
"""
|
||||
Pulses of one line: n_pulses detector gates of width exp_time - readout_time every
|
||||
exp_time, the first one shutter_delay after the trigger edge. The shutter pulse
|
||||
lasts from the edge to the end of the last gate. Written to the PandA only when the
|
||||
values change; must not be called while lines are running.
|
||||
"""
|
||||
n_pulses = int(n_pulses)
|
||||
width = exp_time - readout_time
|
||||
if n_pulses < 1:
|
||||
raise PandaLineTriggerError(f"Number of pulses must be at least 1, got {n_pulses}.")
|
||||
if width <= 0:
|
||||
raise PandaLineTriggerError(
|
||||
f"readout_time {readout_time} s must be shorter than exp_time {exp_time} s."
|
||||
)
|
||||
if shutter_delay < 0:
|
||||
raise PandaLineTriggerError(f"shutter_delay must be >= 0, got {shutter_delay}.")
|
||||
params = {
|
||||
"n_pulses": n_pulses,
|
||||
"width": width,
|
||||
"step": exp_time,
|
||||
"delay": shutter_delay,
|
||||
"shutter_width": shutter_delay + (n_pulses - 1) * exp_time + width,
|
||||
}
|
||||
if params == self._pulse_params:
|
||||
return
|
||||
sp, dp = self.SHUTTER_PULSE, self.DETECTOR_PULSE
|
||||
fields = [
|
||||
(f"{dp}.DELAY", f"{params['delay']:.9f}"),
|
||||
(f"{dp}.WIDTH", f"{params['width']:.9f}"),
|
||||
(f"{dp}.STEP", f"{params['step']:.9f}"),
|
||||
(f"{dp}.PULSES", params["n_pulses"]),
|
||||
(f"{sp}.DELAY", "0"),
|
||||
(f"{sp}.WIDTH", f"{params['shutter_width']:.9f}"),
|
||||
(f"{sp}.STEP", "0"),
|
||||
(f"{sp}.PULSES", 1),
|
||||
]
|
||||
previous = self._pulse_params
|
||||
self._pulse_params = None
|
||||
self.panda.set(f"{self.ARM_GATE}.FORCE_RST", "")
|
||||
written = [field for field, value in fields if self._set_if_changed(field, value)]
|
||||
if written:
|
||||
self._wait_pulses_idle(
|
||||
2 * max(self._train_duration(previous), self._train_duration(params)) + 1
|
||||
)
|
||||
self._pulse_params = params
|
||||
|
||||
def _wait_pulses_idle(self, timeout: float) -> None:
|
||||
"""Wait until a replayed train is over: both PULSE outputs low for PARAM_QUIET_TIME."""
|
||||
sp, dp = self.SHUTTER_PULSE, self.DETECTOR_PULSE
|
||||
deadline = time.time() + timeout + self.PARAM_QUIET_TIME
|
||||
last_active = time.time()
|
||||
while True:
|
||||
time.sleep(0.005)
|
||||
now = time.time()
|
||||
if self.panda.get(f"{sp}.OUT") != "0" or self.panda.get(f"{dp}.OUT") != "0":
|
||||
last_active = now
|
||||
elif now - last_active >= self.PARAM_QUIET_TIME:
|
||||
return
|
||||
if now > deadline:
|
||||
raise PandaLineTriggerError(
|
||||
f"{self.name}: PULSE blocks still active {timeout:.1f} s after a parameter"
|
||||
" change."
|
||||
)
|
||||
|
||||
def arm(self, one_shot: bool = False) -> None:
|
||||
"""
|
||||
Reset the counters and accept line trigger edges: every edge while no line is
|
||||
running starts a line, until disarm(). one_shot=True accepts only the next edge.
|
||||
"""
|
||||
if self._pulse_params is None:
|
||||
raise PandaLineTriggerError(f"{self.name}: configure_line() before arm().")
|
||||
gate = self.ARM_GATE
|
||||
self.panda.set(f"{gate}.FORCE_RST", "")
|
||||
if self.panda.get(f"{self.RAW_TRIGGER_LUT}.OUT") != "0":
|
||||
raise PandaLineTriggerError(
|
||||
f"{self.name}: line trigger input {self.TRIGGER_INPUT} is high, cannot arm."
|
||||
)
|
||||
self.panda.set(f"{gate}.RST", f"{self.ACCEPTED_TRIGGER_LUT}.OUT" if one_shot else "ZERO")
|
||||
# rising edge of ENABLE resets a counter to START
|
||||
for counter in (self.TRIGGER_COUNTER, self.LINE_COUNTER, self.PULSE_COUNTER):
|
||||
self.panda.set(f"{counter}.ENABLE", "ZERO")
|
||||
self.panda.set(f"{counter}.ENABLE", "ONE")
|
||||
self._dropped_at_arm = int(self.panda.get(f"{self.DETECTOR_PULSE}.DROPPED"))
|
||||
self._ignored_reported = 0
|
||||
self.panda.set(f"{gate}.FORCE_SET", "")
|
||||
|
||||
def disarm(self) -> None:
|
||||
"""Ignore line trigger edges until the next arm() (a running line continues)."""
|
||||
self.panda.set(f"{self.ARM_GATE}.FORCE_RST", "")
|
||||
self.panda.set(self.SOFT_TRIGGER_BIT, "0")
|
||||
|
||||
def soft_line_trigger(self) -> None:
|
||||
"""Line trigger edge from software (accepted like a TTLIN3 edge)."""
|
||||
self.panda.set(self.SOFT_TRIGGER_BIT, "1")
|
||||
self.panda.set(self.SOFT_TRIGGER_BIT, "0")
|
||||
|
||||
def line_status(self) -> dict:
|
||||
"""Counts since arm(): trigger edges, accepted lines, detector pulses."""
|
||||
sp, dp = self.SHUTTER_PULSE, self.DETECTOR_PULSE
|
||||
return {
|
||||
"armed": self.panda.get(f"{self.ARM_GATE}.OUT") == "1",
|
||||
"busy": self.panda.get(f"{sp}.OUT") == "1" or self.panda.get(f"{dp}.OUT") == "1",
|
||||
"triggers": int(self.panda.get(f"{self.TRIGGER_COUNTER}.OUT")),
|
||||
"lines": int(self.panda.get(f"{self.LINE_COUNTER}.OUT")),
|
||||
"pulses": int(self.panda.get(f"{self.PULSE_COUNTER}.OUT")),
|
||||
"dropped": int(self.panda.get(f"{dp}.DROPPED")) - self._dropped_at_arm,
|
||||
}
|
||||
|
||||
def wait_lines_done(self, n_lines: int, timeout: float, poll: float = 0.02) -> dict:
|
||||
"""
|
||||
Wait until n_lines lines were triggered and all their pulses sent. Fails on timeout
|
||||
and on more lines or pulses than expected. Ignored trigger edges (edge during a
|
||||
running line, e.g. encoder noise at the PSO window entry; the DDG ignores them too)
|
||||
are only logged.
|
||||
"""
|
||||
n_pulses = self._pulse_params["n_pulses"] if self._pulse_params else 0
|
||||
start = time.time()
|
||||
while True:
|
||||
status = self.line_status()
|
||||
if status["dropped"]:
|
||||
raise PandaLineTriggerError(
|
||||
f"{self.name}: {status['dropped']} trigger(s) dropped by {self.DETECTOR_PULSE}."
|
||||
)
|
||||
if (
|
||||
status["lines"] >= n_lines
|
||||
and status["pulses"] >= status["lines"] * n_pulses
|
||||
and not status["busy"]
|
||||
):
|
||||
break
|
||||
if time.time() - start > timeout:
|
||||
raise PandaLineTriggerError(
|
||||
f"{self.name}: {n_lines} line(s) not finished within {timeout} s: {status}."
|
||||
f" Was the line trigger received on {self.TRIGGER_INPUT}?"
|
||||
)
|
||||
time.sleep(poll)
|
||||
if status["lines"] != n_lines or status["pulses"] != n_lines * n_pulses:
|
||||
raise PandaLineTriggerError(
|
||||
f"{self.name}: expected {n_lines} line(s) with {n_lines * n_pulses} pulses,"
|
||||
f" got {status['lines']} with {status['pulses']}."
|
||||
)
|
||||
# Ignored edges are harmless (lines and pulses are checked above): typically encoder
|
||||
# noise at the PSO window entry, one extra edge per line while the line runs. Log only
|
||||
# the new ones, as info, so a scan does not print a growing warning after each line.
|
||||
ignored = status["triggers"] - status["lines"]
|
||||
if ignored > self._ignored_reported:
|
||||
logger.info(
|
||||
f"{self.name}: {ignored - self._ignored_reported} trigger edge(s) ignored in"
|
||||
f" line {n_lines} (edge while a line was running or while not armed),"
|
||||
f" {ignored} since arm."
|
||||
)
|
||||
self._ignored_reported = ignored
|
||||
return status
|
||||
@@ -0,0 +1,508 @@
|
||||
"""
|
||||
Simulated Aerotech A3200 controller (ASCII command interface).
|
||||
|
||||
Implements the command subset used by `A3200Controller`: ~VERSION, ~LASTERROR, ~STATUS,
|
||||
ENABLE, DISABLE, MOVEABS, MOVEINC, ABORT, HOMEASYNC, FAULTACK, ACKNOWLEDGEALL, RAMP,
|
||||
$global assignments (numbers, UNITSTOCOUNTS), PSOCONTROL/PSOWINDOW/PSOOUTPUT (window mode),
|
||||
DATAACQ and ARRAY READ.
|
||||
Replies follow the real interface with "Always Send EOS": "%<data>\\n", "!\\n" or "#\\n".
|
||||
|
||||
Simulated behavior:
|
||||
- constant-velocity motion with linear position interpolation (no ramps)
|
||||
- HOMEASYNC moves the axis to 0 at the home speed and sets the Homed bit
|
||||
- moving a disabled axis is a task fault ("#"), like on the real controller
|
||||
- faults can be injected via `SimA3200State.axis(name).fault`, FAULTACK clears them
|
||||
- encoder counter of a channel = position * counts_per_unit + counter_offset
|
||||
- detector exposures: when a move starts with the PSO window armed and DATAACQ armed on
|
||||
UINT1, the armed number of triggers is latched immediately, evenly spread over the
|
||||
PSO window of the moving axis (other channels at their current position)
|
||||
- PSOCONTROL FIRE latches one capture if DATAACQ is armed on the PSO firing event
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from csaxs_bec.devices.aerotech.a3200 import (
|
||||
AXISSTATUS_HOMED,
|
||||
AXISSTATUS_HOMING,
|
||||
AXISSTATUS_JOGGING,
|
||||
AXISSTATUS_MOVE_DONE,
|
||||
DRIVESTATUS_ENABLED,
|
||||
DRIVESTATUS_IN_POSITION,
|
||||
A3200Motor,
|
||||
A3200PositionCapture,
|
||||
)
|
||||
from csaxs_bec.devices.sim.sim_socket import SimSocketBase, SimStateRegistry
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_STATUS_ITEM_RE = re.compile(r"\(([^)]*)\)")
|
||||
_GLOBAL_RE = re.compile(r"^\$global\[(\d+)\]$", re.IGNORECASE)
|
||||
_UNITSTOCOUNTS_RE = re.compile(r"^UNITSTOCOUNTS\(\s*(\w+)\s*,\s*([-+0-9.eE]+)\s*\)$", re.I)
|
||||
|
||||
|
||||
class _SimA3200Axis:
|
||||
"""State of one simulated A3200 axis (positions in user units)."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.enabled = False
|
||||
self.homed = False
|
||||
self.homing = False
|
||||
self.fault = 0
|
||||
self.home_velocity = 10.0
|
||||
self.counts_per_unit = 10000.0
|
||||
self.counter_offset = 0
|
||||
self.ramp_rate = None
|
||||
self.settle_time = 0.0 # In Position this long after the end of a move
|
||||
self.settle_error = 0.0 # position error during settle_time
|
||||
self.pos = 0.0
|
||||
self._move_start = 0.0
|
||||
self._move_target = 0.0
|
||||
self._move_t0 = 0.0
|
||||
self._move_t1 = 0.0
|
||||
|
||||
def position(self) -> float:
|
||||
now = time.time()
|
||||
if now >= self._move_t1:
|
||||
return self.pos
|
||||
frac = (now - self._move_t0) / (self._move_t1 - self._move_t0)
|
||||
return self._move_start + frac * (self._move_target - self._move_start)
|
||||
|
||||
def is_moving(self) -> bool:
|
||||
moving = time.time() < self._move_t1
|
||||
if not moving and self.homing:
|
||||
self.homing = False
|
||||
self.homed = True
|
||||
return moving
|
||||
|
||||
def start_move(self, target: float, velocity: float):
|
||||
current = self.position()
|
||||
self._move_start = current
|
||||
self._move_target = target
|
||||
self._move_t0 = time.time()
|
||||
self._move_t1 = self._move_t0 + abs(target - current) / abs(velocity)
|
||||
self.pos = target
|
||||
|
||||
def abort(self):
|
||||
self.pos = self.position()
|
||||
self._move_t1 = 0.0
|
||||
self.homing = False
|
||||
|
||||
def axis_status(self) -> int:
|
||||
moving = self.is_moving() # first: finishes a homing move
|
||||
status = 0
|
||||
if self.homed:
|
||||
status |= AXISSTATUS_HOMED
|
||||
if self.homing:
|
||||
status |= AXISSTATUS_HOMING
|
||||
if moving:
|
||||
status |= AXISSTATUS_JOGGING
|
||||
else:
|
||||
status |= AXISSTATUS_MOVE_DONE
|
||||
return status
|
||||
|
||||
def position_error(self) -> float:
|
||||
if self.is_moving() or time.time() < self._move_t1 + self.settle_time:
|
||||
return self.settle_error
|
||||
return 0.0
|
||||
|
||||
def drive_status(self) -> int:
|
||||
status = DRIVESTATUS_ENABLED if self.enabled else 0
|
||||
if not self.is_moving() and time.time() >= self._move_t1 + self.settle_time:
|
||||
status |= DRIVESTATUS_IN_POSITION
|
||||
return status
|
||||
|
||||
|
||||
class SimA3200State:
|
||||
"""Shared state of one simulated A3200 controller."""
|
||||
|
||||
VERSION = "6.4.4.16"
|
||||
|
||||
def __init__(self, host, port):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._axes = {}
|
||||
self._lock = threading.RLock()
|
||||
self.globals = {}
|
||||
self.channel_axis = {} # DATAACQ source -> axis name
|
||||
self.pso_input_axis = {} # PSO input -> (axis name, counts divider)
|
||||
self.drive_array = [0.0] * 4096
|
||||
self.pso = {"armed": False, "mode": None, "channel": None, "range": None, "load": 0}
|
||||
self.pso_window_entries = 0 # rising edges of the PSO window output (line triggers)
|
||||
self.dataacq = {"armed": False, "trigger": None, "inputs": set(), "n_elements": 0}
|
||||
|
||||
def counter(self, channel: int) -> int:
|
||||
ax = self.axis(self.channel_axis[channel])
|
||||
return int(round(ax.position() * ax.counts_per_unit)) + ax.counter_offset
|
||||
|
||||
def latch(self, positions: dict[str, float] | None = None, index: int = 0) -> None:
|
||||
"""Store one DATAACQ sample (all inputs, ascending channel) at trigger `index`."""
|
||||
inputs = sorted(self.dataacq["inputs"])
|
||||
for i, ch in enumerate(inputs):
|
||||
ax = self.axis(self.channel_axis[ch])
|
||||
if positions and ax.name in positions:
|
||||
value = int(round(positions[ax.name] * ax.counts_per_unit)) + ax.counter_offset
|
||||
else:
|
||||
value = self.counter(ch)
|
||||
self.drive_array[index * len(inputs) + i] = float(value)
|
||||
|
||||
def on_move_start(self, ax: _SimA3200Axis, start: float, target: float) -> None:
|
||||
"""Simulate the line trigger and the detector exposures of a scan line."""
|
||||
pso, acq = self.pso, self.dataacq
|
||||
if not (pso["armed"] and pso["mode"] == "window" and pso["range"] is not None):
|
||||
return
|
||||
pso_axis, divider = self.pso_input_axis.get(pso["channel"], (None, 1))
|
||||
if pso_axis != ax.name:
|
||||
return
|
||||
pso_counts_per_unit = ax.counts_per_unit / divider
|
||||
low = start + pso["range"][0] / pso_counts_per_unit
|
||||
high = start + pso["range"][1] / pso_counts_per_unit
|
||||
if not (min(start, target) <= low and high <= max(start, target)):
|
||||
return
|
||||
self.pso_window_entries += 1
|
||||
if acq["armed"] and acq["trigger"] in (0, 3) and acq["inputs"]:
|
||||
n_triggers = acq["n_elements"] // len(acq["inputs"])
|
||||
step = (high - low) / max(n_triggers, 1)
|
||||
for k in range(n_triggers):
|
||||
self.latch({ax.name: low + k * step}, index=k)
|
||||
acq["armed"] = False
|
||||
|
||||
def axis(self, name: str) -> _SimA3200Axis:
|
||||
with self._lock:
|
||||
key = name.upper()
|
||||
if key not in self._axes:
|
||||
self._axes[key] = _SimA3200Axis(key)
|
||||
return self._axes[key]
|
||||
|
||||
def configure_axis(
|
||||
self,
|
||||
name,
|
||||
initial_position=None,
|
||||
enabled=None,
|
||||
homed=None,
|
||||
home_velocity=None,
|
||||
counts_per_unit=None,
|
||||
counter_offset=None,
|
||||
pso_input=None,
|
||||
pso_counts_divider=1,
|
||||
):
|
||||
ax = self.axis(name)
|
||||
if counts_per_unit is not None:
|
||||
ax.counts_per_unit = float(counts_per_unit)
|
||||
if counter_offset is not None:
|
||||
ax.counter_offset = int(counter_offset)
|
||||
if pso_input is not None:
|
||||
self.pso_input_axis[int(pso_input)] = (ax.name, float(pso_counts_divider))
|
||||
if initial_position is not None:
|
||||
ax.pos = float(initial_position)
|
||||
if enabled is not None:
|
||||
ax.enabled = bool(enabled)
|
||||
if homed is not None:
|
||||
ax.homed = bool(homed)
|
||||
if home_velocity is not None:
|
||||
ax.home_velocity = float(home_velocity)
|
||||
|
||||
|
||||
class _TaskFault(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SimA3200Socket(SimSocketBase):
|
||||
state_cls = SimA3200State
|
||||
|
||||
def __init__(self, host, port, socket_timeout: int = 2):
|
||||
super().__init__(host, port, socket_timeout)
|
||||
self._last_error = "No Error"
|
||||
|
||||
def handle_command(self, line: str):
|
||||
try:
|
||||
data = self._dispatch(line)
|
||||
except _TaskFault as exc:
|
||||
self._last_error = str(exc)
|
||||
return "#\n"
|
||||
except (ValueError, IndexError):
|
||||
self._last_error = f"Invalid command: {line}"
|
||||
return "!\n"
|
||||
if data is None:
|
||||
return "!\n"
|
||||
return f"%{data}\n"
|
||||
|
||||
def _dispatch(self, line: str):
|
||||
state = self.state
|
||||
words = line.split()
|
||||
cmd = words[0].upper()
|
||||
args = words[1:]
|
||||
|
||||
if cmd == "~VERSION":
|
||||
return state.VERSION
|
||||
if cmd == "~LASTERROR":
|
||||
return self._last_error
|
||||
if cmd == "~STATUS":
|
||||
return " ".join(self._status_item(item) for item in _STATUS_ITEM_RE.findall(line))
|
||||
if cmd == "ENABLE":
|
||||
for name in self._require_axes(args):
|
||||
state.axis(name).enabled = True
|
||||
return ""
|
||||
if cmd == "DISABLE":
|
||||
for name in self._require_axes(args):
|
||||
ax = state.axis(name)
|
||||
ax.abort()
|
||||
ax.enabled = False
|
||||
return ""
|
||||
if cmd in ("MOVEABS", "MOVEINC"):
|
||||
name, value, velocity = args[0], float(args[1]), float(args[2])
|
||||
if velocity == 0:
|
||||
raise _TaskFault("Speed of zero is not allowed.")
|
||||
ax = self._ready_axis(name)
|
||||
start = ax.position()
|
||||
target = value if cmd == "MOVEABS" else start + value
|
||||
state.on_move_start(ax, start, target)
|
||||
ax.start_move(target, velocity)
|
||||
return ""
|
||||
if cmd == "HOMEASYNC":
|
||||
for name in self._require_axes(args):
|
||||
ax = self._ready_axis(name)
|
||||
ax.homing = True
|
||||
ax.homed = False
|
||||
ax.start_move(0.0, ax.home_velocity)
|
||||
return ""
|
||||
if cmd == "ABORT":
|
||||
for name in self._require_axes(args):
|
||||
state.axis(name).abort()
|
||||
return ""
|
||||
if cmd == "FAULTACK":
|
||||
for name in self._require_axes(args):
|
||||
state.axis(name).fault = 0
|
||||
return ""
|
||||
if cmd == "ACKNOWLEDGEALL":
|
||||
for ax in state._axes.values():
|
||||
ax.fault = 0
|
||||
return ""
|
||||
if cmd == "RAMP":
|
||||
# RAMP MODE RATE <axis> | RAMP RATE <axis> <value>
|
||||
if args[0].upper() == "RATE" and len(args) == 3:
|
||||
state.axis(args[1]).ramp_rate = float(args[2])
|
||||
return ""
|
||||
if _GLOBAL_RE.match(words[0]) and len(words) >= 3 and words[1] == "=":
|
||||
index = int(_GLOBAL_RE.match(words[0]).group(1))
|
||||
state.globals[index] = self._evaluate(line.split("=", 1)[1].strip())
|
||||
return ""
|
||||
if cmd == "PSOCONTROL":
|
||||
return self._psocontrol(args)
|
||||
if cmd == "PSOWINDOW":
|
||||
# PSOWINDOW <axis> 1 INPUT <ch> | LOAD <v> | RANGE <lo>, <hi> | OFF | ON
|
||||
sub = args[2].upper()
|
||||
if sub == "INPUT":
|
||||
state.pso["channel"] = int(args[3])
|
||||
elif sub == "LOAD":
|
||||
state.pso["load"] = int(args[3])
|
||||
elif sub == "RANGE":
|
||||
low, high = (float(v) for v in " ".join(args[3:]).split(","))
|
||||
state.pso["range"] = (low, high)
|
||||
elif sub == "OFF":
|
||||
state.pso["range"] = None
|
||||
return ""
|
||||
if cmd == "PSOOUTPUT":
|
||||
state.pso["mode"] = args[1].lower()
|
||||
return ""
|
||||
if cmd == "DATAACQ":
|
||||
# DATAACQ <axis> 1 TRIGGER <t> | INPUT <ch> | ARRAY <start>, <n> | OFF
|
||||
sub = args[2].upper()
|
||||
acq = state.dataacq
|
||||
if sub == "OFF":
|
||||
acq.update(armed=False, trigger=None, inputs=set(), n_elements=0)
|
||||
elif sub == "TRIGGER":
|
||||
acq["trigger"] = int(args[3])
|
||||
elif sub == "INPUT":
|
||||
ch = int(args[3])
|
||||
if ch not in state.channel_axis:
|
||||
raise _TaskFault(f"No simulated axis on encoder channel {ch}.")
|
||||
acq["inputs"].add(ch)
|
||||
elif sub == "ARRAY":
|
||||
_, n_elements = (int(v) for v in " ".join(args[3:]).split(","))
|
||||
acq.update(armed=True, n_elements=n_elements)
|
||||
return ""
|
||||
if cmd == "ARRAY":
|
||||
# ARRAY <axis> READ $global[<i>] <start> <n> [FAST]
|
||||
base = int(_GLOBAL_RE.match(args[2]).group(1))
|
||||
start, count = int(args[3]), int(args[4])
|
||||
for i in range(count):
|
||||
state.globals[base + i] = state.drive_array[start + i]
|
||||
return ""
|
||||
logger.warning(f"[sim a3200] {self.host}:{self.port} unhandled command '{line}'")
|
||||
return None
|
||||
|
||||
def _psocontrol(self, args):
|
||||
state = self.state
|
||||
mode = args[1].upper()
|
||||
if mode == "RESET":
|
||||
state.pso.update(armed=False, mode=None, channel=None, range=None, load=0)
|
||||
elif mode == "ARM":
|
||||
state.pso["armed"] = True
|
||||
elif mode == "OFF":
|
||||
state.pso["armed"] = False
|
||||
elif mode == "FIRE":
|
||||
acq = state.dataacq
|
||||
if acq["armed"] and acq["trigger"] == 6:
|
||||
state.latch()
|
||||
acq["armed"] = False
|
||||
return ""
|
||||
|
||||
def _evaluate(self, expression: str) -> float:
|
||||
match = _UNITSTOCOUNTS_RE.match(expression)
|
||||
if match:
|
||||
return float(match.group(2)) * self.state.axis(match.group(1)).counts_per_unit
|
||||
return float(expression)
|
||||
|
||||
@staticmethod
|
||||
def _require_axes(args):
|
||||
if not args:
|
||||
raise ValueError("No axis given.")
|
||||
return args
|
||||
|
||||
def _ready_axis(self, name: str) -> _SimA3200Axis:
|
||||
ax = self.state.axis(name)
|
||||
if not ax.enabled:
|
||||
raise _TaskFault("The axis is not enabled.")
|
||||
if ax.fault:
|
||||
raise _TaskFault("The axis has a fault.")
|
||||
return ax
|
||||
|
||||
def _status_item(self, item: str) -> str:
|
||||
parts = [p.strip() for p in item.split(",")]
|
||||
if len(parts) == 1 and _GLOBAL_RE.match(parts[0]):
|
||||
return f"{self.state.globals.get(int(_GLOBAL_RE.match(parts[0]).group(1)), 0.0):.6f}"
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f"Unsupported status item ({item})")
|
||||
ax = self.state.axis(parts[0])
|
||||
data_item = parts[1]
|
||||
if data_item in (
|
||||
"PositionFeedback",
|
||||
"ProgramPositionFeedback",
|
||||
"PositionCommand",
|
||||
"ProgramPositionCommand",
|
||||
):
|
||||
return f"{ax.position():.6f}"
|
||||
if data_item == "PositionError":
|
||||
return f"{ax.position_error():.9f}"
|
||||
if data_item == "AxisStatus":
|
||||
return str(ax.axis_status())
|
||||
if data_item == "DriveStatus":
|
||||
return str(ax.drive_status())
|
||||
if data_item == "AxisFault":
|
||||
return str(ax.fault)
|
||||
raise ValueError(f"Unsupported status item ({item})")
|
||||
|
||||
|
||||
class SimA3200Motor(A3200Motor):
|
||||
"""A3200Motor connected to the simulated A3200 controller.
|
||||
|
||||
Note: the signature must list all deviceConfig keys explicitly. The BEC device
|
||||
server only passes config keys that appear in inspect.signature(dev_cls) and only
|
||||
injects device_manager if it is an explicit parameter.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
axis_Id,
|
||||
prefix="",
|
||||
*,
|
||||
name,
|
||||
axis_nr=0,
|
||||
host="localhost",
|
||||
port=8000,
|
||||
limits=None,
|
||||
sign=1,
|
||||
velocity=1.0,
|
||||
egu="mm",
|
||||
tolerance=0.001,
|
||||
require_homed=True,
|
||||
acceleration=None,
|
||||
max_scan_velocity=None,
|
||||
pso_input=None,
|
||||
pso_counts_divider=1,
|
||||
device_manager=None,
|
||||
sim_initial_position=None,
|
||||
sim_enabled=True,
|
||||
sim_homed=True,
|
||||
sim_home_velocity=None,
|
||||
sim_counts_per_unit=None,
|
||||
sim_counter_offset=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
axis_Id,
|
||||
prefix,
|
||||
name=name,
|
||||
axis_nr=axis_nr,
|
||||
host=host,
|
||||
port=port,
|
||||
limits=limits,
|
||||
sign=sign,
|
||||
velocity=velocity,
|
||||
egu=egu,
|
||||
tolerance=tolerance,
|
||||
require_homed=require_homed,
|
||||
acceleration=acceleration,
|
||||
max_scan_velocity=max_scan_velocity,
|
||||
pso_input=pso_input,
|
||||
pso_counts_divider=pso_counts_divider,
|
||||
socket_cls=SimA3200Socket,
|
||||
device_manager=device_manager,
|
||||
**kwargs,
|
||||
)
|
||||
state = SimStateRegistry.get(SimA3200State, host, port)
|
||||
state.configure_axis(
|
||||
axis_Id,
|
||||
initial_position=(
|
||||
None if sim_initial_position is None else sim_initial_position * sign
|
||||
),
|
||||
enabled=sim_enabled,
|
||||
homed=sim_homed,
|
||||
home_velocity=sim_home_velocity,
|
||||
counts_per_unit=sim_counts_per_unit,
|
||||
counter_offset=sim_counter_offset,
|
||||
pso_input=pso_input,
|
||||
pso_counts_divider=pso_counts_divider,
|
||||
)
|
||||
|
||||
|
||||
class SimA3200PositionCapture(A3200PositionCapture):
|
||||
"""A3200PositionCapture connected to the simulated A3200 controller."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
prefix="",
|
||||
*,
|
||||
name,
|
||||
host="localhost",
|
||||
port=8000,
|
||||
channels=None,
|
||||
drive_axis="X",
|
||||
trigger_input=0,
|
||||
device_manager=None,
|
||||
scan_info=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
prefix,
|
||||
name=name,
|
||||
host=host,
|
||||
port=port,
|
||||
channels=channels,
|
||||
drive_axis=drive_axis,
|
||||
trigger_input=trigger_input,
|
||||
socket_cls=SimA3200Socket,
|
||||
device_manager=device_manager,
|
||||
scan_info=scan_info,
|
||||
**kwargs,
|
||||
)
|
||||
state = SimStateRegistry.get(SimA3200State, host, port)
|
||||
for channel, axis in self.channels.items():
|
||||
state.channel_axis[channel] = axis.upper()
|
||||
@@ -75,10 +75,20 @@ scans.grid_scan(dev.sgx, -1, 1, 11, dev.sgchi, 0, 60, 7, exp_time=0.1, relative=
|
||||
See `csaxs_bec/device_configs/smargon.yaml` (real) and `smargon_sim_session.yaml`
|
||||
(laptop simulation). All entries for one goniometer must share `host`/`port` so they
|
||||
share the controller. Motor `deviceConfig` keys: `scs_axis`, `host`, `port`, `limits`
|
||||
`[low, high]` (**verify per build**), `tolerance`, `poll_interval`, `move_timeout`.
|
||||
`[low, high]` (**verify per build**), `tolerance`, `poll_interval`, `move_timeout`,
|
||||
`monitor_interval` (readback monitor, see below).
|
||||
Simulation: `simulation: true` (+ optional `gradual_motion: true`) uses `FakeTransport`,
|
||||
no server required.
|
||||
|
||||
## Readback monitor (moves made outside BEC)
|
||||
|
||||
The shared controller polls the SCS readback of all axes (`/readbackSCS`, one request)
|
||||
every `monitor_interval` seconds (motor config, default 0.5 s, `0` disables) and
|
||||
publishes the axes that changed. Moves made in the smargopolo web interface therefore
|
||||
show up in BEC (`wm`, motor widgets, scan baselines) within about one interval. The
|
||||
monitor skips a cycle while BEC itself moves an axis (that move publishes its own
|
||||
readback), logs a connection failure once, and stops with the last destroyed axis.
|
||||
|
||||
## Limits & coupled-axis faults
|
||||
|
||||
SCS soft limits are **user-set** (per experiment), not hardware ranges: a SmarGon's
|
||||
|
||||
@@ -36,6 +36,11 @@ except Exception: # pragma: no cover
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Readback monitor: polling interval (s) and the change below which an axis is not
|
||||
# republished (SCS units, mm / deg)
|
||||
DEFAULT_MONITOR_INTERVAL = 0.5
|
||||
MONITOR_DEADBAND = 1e-6
|
||||
|
||||
|
||||
class SmargopoloController(OphydObject):
|
||||
"""One shared control plane for a smargopolo server (singleton per host/port).
|
||||
@@ -99,6 +104,10 @@ class SmargopoloController(OphydObject):
|
||||
self.transport = RestTransport(host=host, port=port, timeout=timeout)
|
||||
self._axes: dict[Axis, object] = {} # scs_axis -> SmargopoloMotor (registered by motors)
|
||||
self._connected = False
|
||||
# readback monitor: follows moves made outside BEC (smargopolo web interface)
|
||||
self.monitor_interval = DEFAULT_MONITOR_INTERVAL
|
||||
self._monitor_thread: threading.Thread | None = None
|
||||
self._monitor_stop = threading.Event()
|
||||
self._initialized = True
|
||||
|
||||
# -- axis registry -----------------------------------------------------
|
||||
@@ -107,16 +116,71 @@ class SmargopoloController(OphydObject):
|
||||
"""Called by each :class:`SmargopoloMotor` so the controller can find it."""
|
||||
self._axes[Axis(motor.scs_axis)] = motor
|
||||
|
||||
def unregister_axis(self, motor) -> None:
|
||||
"""Called when a motor is destroyed; the monitor stops with the last motor."""
|
||||
axis = Axis(motor.scs_axis)
|
||||
if self._axes.get(axis) is motor:
|
||||
del self._axes[axis]
|
||||
if not self._axes:
|
||||
self.stop_monitor()
|
||||
|
||||
# -- readback monitor --------------------------------------------------
|
||||
|
||||
def start_monitor(self) -> None:
|
||||
"""
|
||||
Poll the SCS readback of all axes every monitor_interval seconds and publish the
|
||||
axes that changed, so moves made outside BEC (smargopolo web interface) show up
|
||||
in BEC. One REST call per cycle. monitor_interval 0 disables it.
|
||||
"""
|
||||
if self.monitor_interval <= 0:
|
||||
return
|
||||
if self._monitor_thread is not None and self._monitor_thread.is_alive():
|
||||
return
|
||||
self._monitor_stop.clear()
|
||||
self._monitor_thread = threading.Thread(
|
||||
target=self._monitor_loop, name=f"{self.name}_monitor", daemon=True
|
||||
)
|
||||
self._monitor_thread.start()
|
||||
|
||||
def stop_monitor(self) -> None:
|
||||
self._monitor_stop.set()
|
||||
thread, self._monitor_thread = self._monitor_thread, None
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
def _monitor_loop(self) -> None:
|
||||
failing = False
|
||||
while not self._monitor_stop.wait(self.monitor_interval):
|
||||
motors = dict(self._axes)
|
||||
# a BEC move publishes its own readback while it polls to the target
|
||||
if not motors or any(m.motor_is_moving.get() for m in motors.values()):
|
||||
continue
|
||||
try:
|
||||
positions = self.get_positions()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
if not failing:
|
||||
logger.warning(f"{self.name}: readback monitor failed: {exc}")
|
||||
failing = True
|
||||
continue
|
||||
if failing:
|
||||
logger.info(f"{self.name}: readback monitor recovered.")
|
||||
failing = False
|
||||
for axis, motor in motors.items():
|
||||
value = positions.get(axis)
|
||||
if value is not None and abs(value - motor.readback.get()) > MONITOR_DEADBAND:
|
||||
motor._update_readback(value) # pylint: disable=protected-access
|
||||
|
||||
# -- connection lifecycle ----------------------------------------------
|
||||
|
||||
def connect(self, timeout: float = 10.0) -> None:
|
||||
with self._lock:
|
||||
if self._connected:
|
||||
return
|
||||
self.transport.connect(timeout=timeout)
|
||||
self._connected = True
|
||||
if not self._connected:
|
||||
self.transport.connect(timeout=timeout)
|
||||
self._connected = True
|
||||
self.start_monitor()
|
||||
|
||||
def close(self) -> None:
|
||||
self.stop_monitor()
|
||||
with self._lock:
|
||||
try:
|
||||
self.transport.close()
|
||||
|
||||
@@ -46,6 +46,10 @@ class SmargopoloMotor(Device, PositionerBase):
|
||||
real coupled hardware limits.
|
||||
tolerance / poll_interval / move_timeout:
|
||||
Move-completion tolerance and polling cadence / timeout.
|
||||
monitor_interval:
|
||||
Interval (s) of the shared controller's readback monitor, which publishes
|
||||
moves made outside BEC (smargopolo web interface). 0 disables it. All axes
|
||||
share one monitor; the value of the last configured axis applies.
|
||||
egu:
|
||||
Engineering unit; defaults to the axis' natural unit (mm or deg).
|
||||
"""
|
||||
@@ -76,6 +80,7 @@ class SmargopoloMotor(Device, PositionerBase):
|
||||
tolerance: float = 0.001,
|
||||
poll_interval: float = 0.1,
|
||||
move_timeout: float = 60.0,
|
||||
monitor_interval: float = 0.5,
|
||||
egu: str | None = None,
|
||||
device_manager=None,
|
||||
kind=None,
|
||||
@@ -111,8 +116,16 @@ class SmargopoloMotor(Device, PositionerBase):
|
||||
self.low_limit_travel.put(float(limits[0]))
|
||||
self.high_limit_travel.put(float(limits[1]))
|
||||
|
||||
self.controller.monitor_interval = float(monitor_interval)
|
||||
self.controller.register_axis(self)
|
||||
|
||||
def destroy(self) -> None:
|
||||
"""Unregister from the shared controller (its monitor stops with the last axis)."""
|
||||
try:
|
||||
self.controller.unregister_axis(self)
|
||||
finally:
|
||||
super().destroy()
|
||||
|
||||
# -- positioner interface ----------------------------------------------
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,4 +2,5 @@ from .flomni_fermat_scan import FlomniFermatScan
|
||||
from .lamni_fermat_scan import LamniFermatScan
|
||||
from .lamni_move_to_scan_center import LamniMoveToScanCenter
|
||||
from .omny_fermat_scan import OmnyFermatScan
|
||||
from .scans_v4.aerotech_cont_grid import AerotechContGrid
|
||||
from .scans_v4.cont_grid import ContGrid
|
||||
@@ -0,0 +1,794 @@
|
||||
"""
|
||||
Continuous grid scan for Aerotech A3200 stages with a position triggered line start.
|
||||
|
||||
The fast axis moves continuously, the step axis steps between lines. The lines always run
|
||||
as a snake (alternating direction). The start of each line is triggered in hardware by
|
||||
the A3200 PSO output of the fast axis, which goes high when the axis enters the PSO window.
|
||||
The trigger positions are
|
||||
|
||||
trigger_low = fast_start - v * shutter_delay + v * readout_time / 2
|
||||
trigger_high = exposure_end + v * shutter_delay - v * readout_time / 2
|
||||
|
||||
The readout term centres each gate (width exp_time - readout_time) in its cell of
|
||||
fast_step_size: without it the forward gates start at the low edge of their cells and the
|
||||
backward gates end at the high edge, a shift of fast_step_size * readout_time / exp_time
|
||||
between the directions (measured 2026-10-29). The readout time is taken from the trigger
|
||||
hardware: DDG2 effective_readout_times()["ab"] (beamline, default 0.2 ms) or
|
||||
get_readout_time() of the PandA device (default 0.2 ms); 0 without trigger hardware.
|
||||
|
||||
The window is open on the far side of the line: [trigger_low, beyond line_end] on
|
||||
forward lines, [beyond line_start, trigger_high] on backward lines, so in both directions
|
||||
the first exposure starts one shutter delay after the trigger at the first point of the
|
||||
line. The axis never leaves the window during a line: with a closed window the exit
|
||||
coincides with the end of the exposures, and encoder noise at the exit edge (seen
|
||||
2026-10-29: 2 extra edges in 11 lines) could start an extra line in the trigger receiver.
|
||||
Noise at the entry edge falls into the running line, which DDG and PandA ignore. The window
|
||||
is armed once per scan (one position reference) and only its range is switched, the trigger
|
||||
receiver stays armed. As soon as the line is triggered, the window is moved to a parking
|
||||
range the axis never reaches: the output goes low while DDG1 / PandA run the line and ignore
|
||||
edges. Switching from the line's window to the next one beyond the exposures instead gave a
|
||||
second trigger per line at the beamline (2026-11-03, DDG1: the switch came just after the
|
||||
DDG1 cycle, which lasts as long as the axis needs from trigger_low to trigger_high). The next
|
||||
line's window is set once the axis has left the exposure range, outside both windows.
|
||||
|
||||
The scan is hardware triggered: the MCS card and the detectors are armed once at stage for
|
||||
all num_lines x frames_per_line frames (num_points = lines, frames_per_trigger = frames per
|
||||
line) and checked for completeness in complete.
|
||||
|
||||
Trigger sources (trigger_source):
|
||||
"ddg": PSO output -> DDG1 EXT (beamline). Every trigger starts one DDG1 burst of
|
||||
frames_per_line gates (shutter / detectors / MCS). After each line the scan
|
||||
checks that the MCS card counted the line's frames. trigger_edge: the Npaq opto
|
||||
output (J7-6/7, sourcing from J7-25 +5 V, into the 50 Ohm DDG input) is high
|
||||
inside the window, so the window entry is a rising edge (default, measured
|
||||
2026-10-29 on the PandA); the open collector (J7-8) and differential (J7-1/2)
|
||||
outputs are active low according to the manual: "falling".
|
||||
"panda": PSO output -> PandABox TTLIN3 (device pandatrig, test setup). The PandA is
|
||||
configured and armed once and sends the shutter pulse and the detector gates
|
||||
of each line, like DDG1 / DDG2: shutter at the trigger, the first gate one
|
||||
shutter-to-open delay later (8 ms, device config shutter_delay).
|
||||
None: no trigger hardware (motion tests); the PSO window is still armed. Only for
|
||||
sessions without hardware triggered detectors / MCS, they would wait for
|
||||
frames that never come.
|
||||
|
||||
The positions are always captured (device aerocapture, Npaq DATAACQ on UINT1 = detector
|
||||
gates): per line the captured positions at the start of each exposure
|
||||
(aerocapture_positions_enc<channel>) and the exposure centres
|
||||
(aerocapture_positions_enc<channel>_centre = captured + v * gate_width / 2 in the direction
|
||||
of motion for the moving fast axis). At the end the scan reports statistics of the
|
||||
centres of the fast axis (deviation from the nominal cell centres per direction,
|
||||
backward - forward offset and its slope along the line) and of the line times.
|
||||
|
||||
Run-up / run-out of each line: twice the ramp distance v^2 / (2 * acceleration) + v *
|
||||
settle_time beyond the trigger positions. The whole range including run-up / run-out is
|
||||
checked against the soft limits before the scan starts. The scan velocity may be higher
|
||||
than the normal move velocity, up to max_scan_velocity of the fast axis; the move velocity
|
||||
is restored after the scan, also after an aborted scan. In a snake the run-out of a line is the run-up of
|
||||
the next one, so both sides are equal. At the turnarounds the fast axis does not wait for
|
||||
In Position. The step axis moves during the run-out and turnaround of the fast axis: as
|
||||
soon as the fast axis has left the exposure range, the PSO window of the next line is set
|
||||
and the step axis starts; the next line starts when both have arrived.
|
||||
|
||||
minimal_run_up=True reduces the run-up / run-out to the ramp distance alone (no margin, no
|
||||
settle_time; at least MIN_RUN_UP = 0.01 so the axis never turns at a PSO window edge), for data analysis that works with unevenly spaced (captured) positions.
|
||||
|
||||
settle_time stays 0.15 s also at high speed: at 20 mm/s / 500 mm/s^2 shorter values
|
||||
(0.08 / 0.04 / 0.02 / 0 s) gave backward - forward offsets of -24 / +10 / +4.5 / -21 um,
|
||||
a ~10 Hz oscillation after the ramp that decays within ~0.15 s (measured 2026-10-29).
|
||||
|
||||
Scan procedure:
|
||||
- prepare_scan
|
||||
- open_scan
|
||||
- stage
|
||||
- pre_scan
|
||||
- scan_core
|
||||
- at_each_point (called by scan_core for each line)
|
||||
- post_scan
|
||||
- unstage
|
||||
- close_scan
|
||||
- on_exception (called if any exception is raised during the scan)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Annotated, TypedDict
|
||||
|
||||
import numpy as np
|
||||
from bec_lib.device import DeviceBase
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.scan_args import DefaultArgType, ScanArgument, Units
|
||||
# Checks raise LimitError / ValueError / RuntimeError, not ScanAbortion: the scan worker
|
||||
# only logs a ScanAbortion, the client then shows just "Scan ... was interrupted". Other
|
||||
# exceptions raise an alarm, so the message is shown in the client.
|
||||
from bec_server.scan_server.errors import LimitError
|
||||
from bec_server.scan_server.scans.scan_base import ScanBase, ScanType
|
||||
from bec_server.scan_server.scans.scan_modifier import scan_hook
|
||||
|
||||
from csaxs_bec.scans.scan_customization.scan_components import CsaxsBecScanComponents
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class _DeviceExample(str):
|
||||
"""Device name for ScanArgument(example=...): rendered as dev.<name> in the generated
|
||||
doc examples, serialized as a plain string in the scan signature."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"dev.{self}"
|
||||
|
||||
|
||||
# DDG trigger sources (csaxs_bec.devices.epics.delay_generator_csaxs.TRIGGERSOURCE)
|
||||
DDG_EXT_RISING_EDGE = 1
|
||||
DDG_EXT_FALLING_EDGE = 2
|
||||
DDG_SINGLE_SHOT = 5
|
||||
DDG_TRIGGER_EDGES = {"rising": DDG_EXT_RISING_EDGE, "falling": DDG_EXT_FALLING_EDGE}
|
||||
|
||||
# Extra run-up / run-out beyond the ramp distance, as a fraction of it (headroom for the
|
||||
# servo following error at the end of the ramp)
|
||||
RAMP_MARGIN_FRACTION = 1.0
|
||||
|
||||
# Smallest run-up / run-out with minimal_run_up, in units of the fast axis: at low speed the
|
||||
# ramp distance is sub-um (0.5 mm/s, 500 mm/s^2: 0.25 um), and the fast axis would stop and
|
||||
# reverse right at the edge of the next line's PSO window, where ringing or overshoot could
|
||||
# start the line before the axis moves.
|
||||
MIN_RUN_UP = 0.01
|
||||
|
||||
TRIGGER_SOURCES = ("ddg", "panda", None)
|
||||
|
||||
# Poll interval of the fast axis position while waiting for the end of the exposures, s
|
||||
STEP_POLL_INTERVAL = 0.01
|
||||
|
||||
|
||||
class AerotechLineParameter(TypedDict):
|
||||
"""Line geometry of the fast axis, computed in prepare_scan."""
|
||||
|
||||
original_velocity: float | None
|
||||
target_velocity: float | None
|
||||
acceleration: float | None
|
||||
shutter_open_delay: float | None
|
||||
readout_time: float | None
|
||||
trigger_low: float | None
|
||||
trigger_high: float | None
|
||||
exposure_end: float | None
|
||||
line_start: float | None
|
||||
line_end: float | None
|
||||
num_lines: int | None
|
||||
|
||||
|
||||
class AerotechContGrid(ScanBase):
|
||||
# One line = one point with frames_per_trigger frames; MCS and detectors are armed once
|
||||
# for all frames of the scan.
|
||||
scan_type = ScanType.HARDWARE_TRIGGERED
|
||||
scan_name = "aerotech_cont_grid"
|
||||
|
||||
gui_config = {
|
||||
"Scan Parameters": [
|
||||
"fast_axis",
|
||||
"fast_start",
|
||||
"fast_end",
|
||||
"fast_step_size",
|
||||
"stepper_axis",
|
||||
"stepper_start",
|
||||
"stepper_stop",
|
||||
"stepper_step_size",
|
||||
],
|
||||
"Acquisition Parameters": [
|
||||
"exp_time",
|
||||
"relative",
|
||||
"trigger_source",
|
||||
"minimal_run_up",
|
||||
],
|
||||
}
|
||||
|
||||
def __init__(
|
||||
# fmt: off
|
||||
self,
|
||||
fast_axis: Annotated[DeviceBase, ScanArgument(display_name="Fast Axis", description="A3200 axis with continuous motion and PSO line trigger.", example=_DeviceExample("aerox"))],
|
||||
fast_start: Annotated[float, ScanArgument(display_name="Fast Start", description="Start position for measurement points of the fast axis.", example=-1.75)],
|
||||
fast_end: Annotated[float, ScanArgument(display_name="Fast End", description="End position for measurement points of the fast axis.", example=1.75)],
|
||||
fast_step_size: Annotated[float, ScanArgument(display_name="Fast Step Size", description="Distance travelled by the fast axis during one exposure.", example=0.04)],
|
||||
stepper_axis: Annotated[DeviceBase, ScanArgument(display_name="Step Axis", description="Step axis of the grid scan, stepping through the lines.", example=_DeviceExample("aeroy"))],
|
||||
stepper_start: Annotated[float, ScanArgument(display_name="Step Start", description="Start position of the step axis.", example=-1.5)],
|
||||
stepper_stop: Annotated[float, ScanArgument(display_name="Step Stop", description="End position of the step axis.", example=1.5)],
|
||||
stepper_step_size: Annotated[float, ScanArgument(display_name="Step Step Size", description="Step size of the step axis in units of the motor.", example=0.04)],
|
||||
exp_time: Annotated[float, ScanArgument(display_name="Exposure Time", description="Exposure time in seconds", units=Units.s, ge=0, example=0.02)],
|
||||
relative: DefaultArgType.Relative = False,
|
||||
trigger_source: str | None = "ddg",
|
||||
trigger_edge: str = "rising",
|
||||
trigger_device: str | None = None,
|
||||
shutter_delay: float | None = None,
|
||||
settle_time: float = 0.15,
|
||||
minimal_run_up: bool = False,
|
||||
capture_device: str = "aerocapture",
|
||||
**kwargs,
|
||||
# fmt: on
|
||||
):
|
||||
"""
|
||||
Continuous grid scan for A3200 stages: the PSO output of the fast axis triggers the
|
||||
start of each line, the axis positions are latched at each detector trigger.
|
||||
|
||||
Args:
|
||||
fast_axis (DeviceBase): A3200 axis with continuous motion (needs pso_input and acceleration configured).
|
||||
fast_start (float): Start position for measurement points of the fast axis.
|
||||
fast_end (float): End position for measurement points of the fast axis.
|
||||
fast_step_size (float): Distance travelled by the fast axis during one exposure.
|
||||
stepper_axis (DeviceBase): Step axis of the grid scan, stepping through the lines.
|
||||
stepper_start (float): Start position of the step axis.
|
||||
stepper_stop (float): End position of the step axis.
|
||||
stepper_step_size (float): Step size of the step axis in units of the motor.
|
||||
exp_time (float): Exposure time (period of the detector gates) in seconds.
|
||||
relative (bool): Whether the positions are relative to the current position. Default is False.
|
||||
trigger_source (str | None): "ddg" (beamline, DDG1/MCS), "panda" (PandABox line trigger) or None (no trigger hardware). Default is "ddg".
|
||||
trigger_edge (str): DDG1 external trigger edge, "rising" (opto output, J7-6/7) or "falling" (open collector / differential output). Default is "rising".
|
||||
trigger_device (str | None): Name of the PandaLineTrigger device for trigger_source="panda"; ignored otherwise. Default is None ("pandatrig").
|
||||
shutter_delay (float | None): Delay from the line trigger to the first exposure (time for the shutter to open) in s. Default: the value of the trigger hardware (DDG1 / PandA shutter-to-open delay, 8 ms), 0 without trigger hardware. With "ddg" a given value is set on DDG1 for this scan and restored afterwards.
|
||||
settle_time (float): Run-up at constant velocity before the trigger position, in s. Default is 0.15.
|
||||
minimal_run_up (bool): Run-up / run-out only the ramp distance v^2 / (2 * acceleration), at least 0.01, without the 2x margin and settle_time: faster lines, but the exposures start right after the ramp while the fast axis may still oscillate, so the captured positions are less evenly spaced. Default is False.
|
||||
capture_device (str): Name of the A3200PositionCapture device (always used). Default is "aerocapture".
|
||||
|
||||
Returns:
|
||||
ScanReport
|
||||
|
||||
Examples:
|
||||
Beamline (trigger_source="ddg"), X fast, Y step, field of view centered on the
|
||||
current position (relative=True). Scan velocity = fast_step_size / exp_time, at
|
||||
most max_scan_velocity of aerox (sastt_aerotech.yaml: 50 mm/s; moves: 5 mm/s).
|
||||
|
||||
Largest field of view, 3.5 mm x 3.0 mm (horizontal x vertical):
|
||||
|
||||
>>> # overview, good statistics: 40 um, 20 ms (2 mm/s), 89 x 76 points
|
||||
>>> scans.aerotech_cont_grid(dev.aerox, -1.75, 1.75, 0.04, dev.aeroy, -1.5, 1.5, 0.04, exp_time=0.02, relative=True)
|
||||
>>> # quick overview: 100 um, 5 ms (20 mm/s), 36 x 31 points
|
||||
>>> scans.aerotech_cont_grid(dev.aerox, -1.75, 1.75, 0.1, dev.aeroy, -1.5, 1.5, 0.1, exp_time=0.005, relative=True)
|
||||
>>> # high resolution, good statistics: 10 um, 20 ms (0.5 mm/s), 351 x 301 points
|
||||
>>> scans.aerotech_cont_grid(dev.aerox, -1.75, 1.75, 0.01, dev.aeroy, -1.5, 1.5, 0.01, exp_time=0.02, relative=True)
|
||||
|
||||
Smallest field of view, 0.8 mm x 1.2 mm (horizontal x vertical):
|
||||
|
||||
>>> # overview, good statistics: 40 um, 20 ms (2 mm/s), 21 x 31 points
|
||||
>>> scans.aerotech_cont_grid(dev.aerox, -0.4, 0.4, 0.04, dev.aeroy, -0.6, 0.6, 0.04, exp_time=0.02, relative=True)
|
||||
>>> # quick overview: 100 um, 5 ms (20 mm/s), 9 x 13 points
|
||||
>>> scans.aerotech_cont_grid(dev.aerox, -0.4, 0.4, 0.1, dev.aeroy, -0.6, 0.6, 0.1, exp_time=0.005, relative=True)
|
||||
>>> # high resolution, good statistics: 10 um, 20 ms (0.5 mm/s), 81 x 121 points
|
||||
>>> scans.aerotech_cont_grid(dev.aerox, -0.4, 0.4, 0.01, dev.aeroy, -0.6, 0.6, 0.01, exp_time=0.02, relative=True)
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.components = CsaxsBecScanComponents(self)
|
||||
self._baseline_readout_status = None
|
||||
if trigger_source not in TRIGGER_SOURCES:
|
||||
raise ValueError(
|
||||
f"trigger_source must be one of {TRIGGER_SOURCES}, got {trigger_source!r}."
|
||||
)
|
||||
if trigger_edge not in DDG_TRIGGER_EDGES:
|
||||
raise ValueError(
|
||||
f"trigger_edge must be one of {list(DDG_TRIGGER_EDGES)}, got {trigger_edge!r}."
|
||||
)
|
||||
self.trigger_edge = trigger_edge
|
||||
self.fast_axis = fast_axis
|
||||
if fast_end < fast_start: # the points are defined in positive direction
|
||||
fast_start, fast_end = fast_end, fast_start
|
||||
self.fast_start = fast_start
|
||||
self.fast_end = fast_end
|
||||
self.fast_step_size = fast_step_size
|
||||
self.stepper_axis = stepper_axis
|
||||
self.stepper_start = stepper_start
|
||||
self.stepper_stop = stepper_stop
|
||||
self.stepper_step_size = stepper_step_size
|
||||
self.relative = relative
|
||||
self.exp_time = exp_time
|
||||
self.trigger_source = trigger_source
|
||||
self.shutter_delay = shutter_delay
|
||||
self._ddg1_shutter_delay = None # DDG1 value to restore after the scan
|
||||
self.settle_time = settle_time
|
||||
self.minimal_run_up = minimal_run_up
|
||||
|
||||
devices = self.device_manager.devices
|
||||
self.ddg1 = self.ddg2 = self.mcs = self.panda = None
|
||||
if trigger_source == "ddg":
|
||||
self.ddg1 = devices["ddg1"]
|
||||
self.ddg2 = devices["ddg2"]
|
||||
self.mcs = devices["mcs"]
|
||||
elif trigger_source == "panda":
|
||||
trigger_device = trigger_device or "pandatrig"
|
||||
if trigger_device not in devices:
|
||||
raise ValueError(f"PandA line trigger device '{trigger_device}' not found.")
|
||||
self.panda = devices[trigger_device]
|
||||
if capture_device not in devices:
|
||||
raise ValueError(
|
||||
f"Position capture device '{capture_device}' not found; aerotech_cont_grid"
|
||||
" always captures the positions (enable it in the device config)."
|
||||
)
|
||||
self.capture = devices[capture_device]
|
||||
self._captured_lines: list[dict] = []
|
||||
self._step_status = None
|
||||
self._line_times: list[float] = [] # line start .. trigger checked
|
||||
self._line_starts: list[float] = []
|
||||
self._readout_times: list[float] = [] # capture readout per line
|
||||
self._last_line_end = None
|
||||
|
||||
self.motors = [fast_axis, stepper_axis]
|
||||
self.frames_per_line = 0
|
||||
self._line: AerotechLineParameter = AerotechLineParameter()
|
||||
self._fast_axis_configured = False
|
||||
self.update_scan_info(relative=relative, exp_time=exp_time)
|
||||
|
||||
@scan_hook
|
||||
def prepare_scan(self):
|
||||
"""Compute positions and line geometry, start the premove."""
|
||||
self._check_motor_inputs()
|
||||
self.frames_per_line = (
|
||||
int(np.ceil(np.abs(self.fast_end - self.fast_start) / self.fast_step_size)) + 1
|
||||
) # include last point
|
||||
num_lines = (
|
||||
int(np.ceil(np.abs(self.stepper_stop - self.stepper_start) / self.stepper_step_size))
|
||||
+ 1
|
||||
) # include last point
|
||||
self._line["num_lines"] = num_lines
|
||||
|
||||
if self.relative:
|
||||
self.start_positions = self.components.get_start_positions(self.motors)
|
||||
self.fast_start += self.start_positions[0]
|
||||
self.fast_end += self.start_positions[0]
|
||||
self.stepper_start += self.start_positions[1]
|
||||
self.stepper_stop += self.start_positions[1]
|
||||
|
||||
# nominal points: the start of each exposure on forward lines; a backward line
|
||||
# exposes the same intervals in reverse order
|
||||
fast_points = self.fast_start + np.arange(self.frames_per_line) * self.fast_step_size
|
||||
stepper_points = np.linspace(self.stepper_start, self.stepper_stop, num_lines)
|
||||
positions = []
|
||||
for line_index, stepper_pos in enumerate(stepper_points):
|
||||
points = fast_points if self._is_forward(line_index) else fast_points[::-1]
|
||||
positions.extend([point, stepper_pos] for point in points)
|
||||
positions = np.array(positions)
|
||||
|
||||
self._compute_line_geometry()
|
||||
self._check_line_limits()
|
||||
self._apply_shutter_delay()
|
||||
|
||||
# One readout per line: the last point of each line, as in cont_grid
|
||||
self.positions = positions[(self.frames_per_line - 1) :: self.frames_per_line, :].copy()
|
||||
|
||||
self.actions.set_device_readout_priority(self.motors, priority="monitored")
|
||||
self.update_scan_info(
|
||||
positions=self.positions,
|
||||
num_points=len(self.positions),
|
||||
frames_per_trigger=self.frames_per_line,
|
||||
computed_positions=positions,
|
||||
num_lines=num_lines,
|
||||
readout_time=self._line["readout_time"],
|
||||
)
|
||||
|
||||
# progress bar in the client, which also prints the statistics at the end: the
|
||||
# MCS frames at the beamline, otherwise the captured lines
|
||||
if self.mcs is not None:
|
||||
self.actions.add_scan_report_instruction_device_progress(self.mcs)
|
||||
else:
|
||||
self.actions.add_scan_report_instruction_device_progress(self.capture)
|
||||
self._baseline_readout_status = self.actions.read_baseline_devices(wait=False)
|
||||
self._premove_motor_status = self.actions.set(
|
||||
self.motors, [self._line["line_start"], self.stepper_start], wait=False
|
||||
)
|
||||
|
||||
@scan_hook
|
||||
def open_scan(self):
|
||||
"""Open the scan."""
|
||||
self.actions.open_scan()
|
||||
|
||||
@scan_hook
|
||||
def stage(self):
|
||||
"""Stage all devices."""
|
||||
self.actions.stage_all_devices()
|
||||
|
||||
@scan_hook
|
||||
def pre_scan(self):
|
||||
"""
|
||||
Calibrate the position capture while no trigger receiver is armed (the calibration
|
||||
fires the PSO output once), then arm the PSO window once for the whole scan, set the
|
||||
scan velocity and arm the trigger receiver.
|
||||
"""
|
||||
self._premove_motor_status.wait()
|
||||
self.capture.calibrate()
|
||||
if "target_velocity" not in self._line: # pre_scan without prepare_scan
|
||||
self._compute_line_geometry()
|
||||
line = self._line
|
||||
self._fast_axis_configured = True
|
||||
self.fast_axis.motor_velocity.set(line["target_velocity"]).wait(timeout=5)
|
||||
self.fast_axis.wait_in_position.set(False).wait(timeout=5)
|
||||
self.fast_axis.arm_line_trigger(*self._trigger_window(0))
|
||||
if self.trigger_source == "ddg":
|
||||
self.ddg1.set_trigger(DDG_TRIGGER_EDGES[self.trigger_edge])
|
||||
elif self.trigger_source == "panda":
|
||||
self.panda.configure_line(
|
||||
self.frames_per_line, self.exp_time, line["readout_time"], line["shutter_open_delay"]
|
||||
)
|
||||
self.panda.arm()
|
||||
self.actions.pre_scan_all_devices()
|
||||
|
||||
@scan_hook
|
||||
def scan_core(self):
|
||||
"""
|
||||
One continuous line per step axis position. The step to the next line is started by
|
||||
at_each_point while the fast axis runs out and turns; it must be done before the
|
||||
next line starts.
|
||||
"""
|
||||
line = self._line
|
||||
self._step_status = None
|
||||
for line_index in range(line["num_lines"]):
|
||||
if self._step_status is not None:
|
||||
self._step_status.wait()
|
||||
self._step_status = None
|
||||
target = line["line_end"] if self._is_forward(line_index) else line["line_start"]
|
||||
self.at_each_point(
|
||||
motors=[self.fast_axis], positions=np.array([target]), line_index=line_index
|
||||
)
|
||||
|
||||
@scan_hook
|
||||
def at_each_point(
|
||||
self,
|
||||
motors: list[str | DeviceBase],
|
||||
positions: np.ndarray,
|
||||
line_index: int,
|
||||
last_positions: np.ndarray | None = None,
|
||||
):
|
||||
"""
|
||||
Run one line: arm the position capture, move the fast axis through the PSO window,
|
||||
park the window once the line is triggered, start the step to the next line once the
|
||||
fast axis has left the exposure range, then check that the line was triggered and
|
||||
read the captured positions.
|
||||
"""
|
||||
line_start_time = time.time()
|
||||
self._line_starts.append(line_start_time)
|
||||
self.capture.arm(self.frames_per_line)
|
||||
move_status = self.actions.set(motors, positions, wait=False)
|
||||
next_line = line_index + 1 if line_index + 1 < self._line["num_lines"] else None
|
||||
# the PSO output goes low while the trigger receiver runs the line and ignores edges
|
||||
while not move_status.done and not self._past_trigger(line_index):
|
||||
time.sleep(STEP_POLL_INTERVAL)
|
||||
self.fast_axis.set_line_trigger_window(*self._parking_window())
|
||||
while next_line is not None and not move_status.done:
|
||||
if self._past_exposures(line_index):
|
||||
break
|
||||
time.sleep(STEP_POLL_INTERVAL)
|
||||
if next_line is not None:
|
||||
self._start_step(next_line)
|
||||
while not move_status.done:
|
||||
self.actions.read_monitored_devices(wait=True)
|
||||
try:
|
||||
move_status.wait(timeout=0.5)
|
||||
except TimeoutError:
|
||||
continue
|
||||
|
||||
if self.trigger_source == "ddg":
|
||||
self._wait_mcs_frames(line_index)
|
||||
elif self.trigger_source == "panda":
|
||||
self.panda.wait_lines_done(line_index + 1, timeout=self._line_duration() + 2)
|
||||
|
||||
self._line_times.append(time.time() - line_start_time)
|
||||
readout_start = time.time()
|
||||
self._captured_lines.append(self.capture.read_line(centre_shift=self._centre_shift()))
|
||||
self._last_line_end = time.time()
|
||||
self._readout_times.append(self._last_line_end - readout_start)
|
||||
|
||||
@scan_hook
|
||||
def post_scan(self):
|
||||
"""Restore trigger receiver, PSO and fast axis settings, complete devices."""
|
||||
self._restore()
|
||||
self._report_statistics()
|
||||
status = self.actions.complete_all_devices(wait=False)
|
||||
if self.relative:
|
||||
self.components.move_and_wait(self.motors, self.start_positions)
|
||||
status.wait()
|
||||
|
||||
@scan_hook
|
||||
def unstage(self):
|
||||
"""Unstage all devices."""
|
||||
self.actions.unstage_all_devices()
|
||||
|
||||
@scan_hook
|
||||
def close_scan(self):
|
||||
"""Close the scan."""
|
||||
if self._baseline_readout_status is not None:
|
||||
self._baseline_readout_status.wait()
|
||||
self.actions.close_scan()
|
||||
self.actions.check_for_unchecked_statuses()
|
||||
|
||||
@scan_hook
|
||||
def on_exception(self, exception: Exception):
|
||||
"""Return to a safe state: no external trigger, PSO off, fast axis settings restored."""
|
||||
self._restore()
|
||||
if self.relative:
|
||||
self.components.move_and_wait(self.motors, self.start_positions)
|
||||
|
||||
#######################################################
|
||||
######### Helper methods for the scan logic ###########
|
||||
#######################################################
|
||||
|
||||
def _is_forward(self, line_index: int) -> bool:
|
||||
return line_index % 2 == 0
|
||||
|
||||
def _past_trigger(self, line_index: int) -> bool:
|
||||
"""The fast axis has entered the PSO window of the line (the line is triggered)."""
|
||||
position = self.fast_axis.readback.get()
|
||||
if self._is_forward(line_index):
|
||||
return position > self._line["trigger_low"]
|
||||
return position < self._line["trigger_high"]
|
||||
|
||||
def _parking_window(self) -> tuple[float, float]:
|
||||
"""PSO window beyond the line end, never reached by the fast axis."""
|
||||
line = self._line
|
||||
span = line["line_end"] - line["line_start"]
|
||||
return line["line_end"] + span, line["line_end"] + 2 * span
|
||||
|
||||
def _past_exposures(self, line_index: int) -> bool:
|
||||
"""The fast axis has left the exposure range of the line (beyond its trigger position)."""
|
||||
position = self.fast_axis.readback.get()
|
||||
if self._is_forward(line_index):
|
||||
return position > self._line["trigger_high"]
|
||||
return position < self._line["trigger_low"]
|
||||
|
||||
def _start_step(self, line_index: int) -> None:
|
||||
"""
|
||||
Prepare line line_index while the fast axis runs out: switch the PSO window (the
|
||||
fast axis is beyond the trigger position, i.e. outside the new window) and start the
|
||||
step axis.
|
||||
"""
|
||||
self.fast_axis.set_line_trigger_window(*self._trigger_window(line_index))
|
||||
self._step_status = self.actions.set(
|
||||
[self.stepper_axis], [self.positions[line_index][1]], wait=False
|
||||
)
|
||||
|
||||
def _readout_time(self) -> float:
|
||||
"""Readout time (gap between the detector gates) of the trigger hardware, in s."""
|
||||
if self.trigger_source == "ddg":
|
||||
return float(self.ddg2.effective_readout_times()["ab"])
|
||||
if self.trigger_source == "panda":
|
||||
return float(self.panda.get_readout_time())
|
||||
return 0.0
|
||||
|
||||
def _centre_shift(self) -> float:
|
||||
"""Distance from the start to the centre of an exposure along the fast axis."""
|
||||
line = self._line
|
||||
return line["target_velocity"] * (self.exp_time - line["readout_time"]) / 2
|
||||
|
||||
def _report_statistics(self):
|
||||
"""Log and send to the client: centre deviations of the fast axis and line times."""
|
||||
try:
|
||||
summary = self._statistics()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(f"aerotech_cont_grid: no statistics: {exc}")
|
||||
return
|
||||
if summary:
|
||||
logger.info(summary)
|
||||
self.actions.send_client_info(summary)
|
||||
|
||||
def _statistics(self) -> str:
|
||||
lines = [d for d in self._captured_lines if d]
|
||||
if not lines:
|
||||
return ""
|
||||
# fast axis = the centre channel with the largest span over a line
|
||||
keys = [k for k in lines[0] if k.endswith("_centre")]
|
||||
fast_key = max(keys, key=lambda k: abs(lines[0][k][-1] - lines[0][k][0]))
|
||||
n, step = self.frames_per_line, self.fast_step_size
|
||||
cells = self.fast_start + step * (np.arange(n) + 0.5)
|
||||
centres = []
|
||||
for line_index, data in enumerate(lines):
|
||||
c = np.asarray(data[fast_key], dtype=float)
|
||||
if len(c) != n:
|
||||
return f"aerotech_cont_grid: line {line_index}: {len(c)} positions, expected {n}."
|
||||
centres.append(c if self._is_forward(line_index) else c[::-1])
|
||||
centres = np.array(centres)
|
||||
dev = (centres - cells) * 1e3 # um, by cell; positions in motor user coordinates
|
||||
fwd, bwd = dev[0::2], dev[1::2]
|
||||
text = [
|
||||
f"aerotech_cont_grid statistics ({len(lines)} lines x {n} points, v"
|
||||
f" {self._line['target_velocity']:g} / s, readout {self._line['readout_time'] * 1e3:g} ms"
|
||||
f"{', minimal run-up' if self.minimal_run_up else ''}):",
|
||||
f" centre - nominal cell centre [um]: forward mean {fwd.mean():+.2f} rms"
|
||||
f" {fwd.std():.2f} max {np.abs(fwd).max():.2f}",
|
||||
]
|
||||
if len(bwd):
|
||||
pairs = min(len(fwd), len(bwd))
|
||||
diff = (bwd[:pairs] - fwd[:pairs]).mean(axis=0)
|
||||
slope = np.polyfit(cells - cells.mean(), diff, 1)[0] if n > 1 else 0.0
|
||||
text += [
|
||||
f" centre - nominal cell centre [um]: backward mean {bwd.mean():+.2f} rms"
|
||||
f" {bwd.std():.2f} max {np.abs(bwd).max():.2f}",
|
||||
f" backward - forward [um]: mean {diff.mean():+.2f}, slope {slope:+.2f} / mm,"
|
||||
f" peak-peak {np.ptp(diff):.2f}",
|
||||
]
|
||||
if self._line_starts and self._last_line_end is not None:
|
||||
# line period: start to start of the next line (motion, trigger check, capture
|
||||
# readout, wait for the step); the last line ends with its capture readout
|
||||
ends = self._line_starts[1:] + [self._last_line_end]
|
||||
periods = np.subtract(ends, self._line_starts)
|
||||
lines_total = self._last_line_end - self._line_starts[0]
|
||||
text.append(
|
||||
f" times: lines {lines_total:.1f} s (first line start to last line end,"
|
||||
f" without staging / premove / close), line period mean {periods.mean():.2f} s"
|
||||
f" (min {periods.min():.2f}, max {periods.max():.2f}), of which motion and"
|
||||
f" trigger check {np.mean(self._line_times):.2f} s, capture readout"
|
||||
f" {np.mean(self._readout_times):.2f} s; exposure per line"
|
||||
f" {n * self.exp_time:.2f} s"
|
||||
)
|
||||
return "\n".join(text)
|
||||
|
||||
def _trigger_window(self, line_index: int) -> tuple[float, float]:
|
||||
"""PSO window of a line: entered at the trigger position, open beyond the line end."""
|
||||
line = self._line
|
||||
span = line["line_end"] - line["line_start"]
|
||||
if self._is_forward(line_index):
|
||||
return line["trigger_low"], line["line_end"] + span
|
||||
return line["line_start"] - span, line["trigger_high"]
|
||||
|
||||
def _wait_mcs_frames(self, line_index: int, timeout: float = 2.0, poll: float = 0.05):
|
||||
"""
|
||||
After line line_index the MCS card must have advanced to the last channel of that
|
||||
line (the last channel of the scan is closed in its complete). Catches a missing
|
||||
line trigger right after the line instead of at the end of the scan, and an extra
|
||||
one: the detectors would count it as well, their frames would no longer match the
|
||||
captured positions.
|
||||
"""
|
||||
expected = (line_index + 1) * self.frames_per_line - 1
|
||||
deadline = time.time() + timeout
|
||||
while (channel := self.mcs.current_channel.get()) < expected:
|
||||
if time.time() > deadline:
|
||||
raise RuntimeError(
|
||||
f"MCS card at channel {channel} after line {line_index}, expected"
|
||||
f" {expected}. Was the line trigger received? Check the PSO output ->"
|
||||
" DDG1 EXT wiring and trigger_edge."
|
||||
)
|
||||
time.sleep(poll)
|
||||
if channel > expected:
|
||||
raise RuntimeError(
|
||||
f"MCS card at channel {channel} after line {line_index}, expected"
|
||||
f" {expected}: more frames than the line has, the trigger receiver was"
|
||||
" triggered more than once. The detector frames do not match the positions."
|
||||
)
|
||||
|
||||
def _line_duration(self) -> float:
|
||||
line = self._line
|
||||
return abs(line["line_end"] - line["line_start"]) / line["target_velocity"]
|
||||
|
||||
def _check_motor_inputs(self):
|
||||
if np.isclose(self.fast_start, self.fast_end, atol=self.fast_step_size):
|
||||
raise ValueError(
|
||||
f"Fast stop {self.fast_end} and fast start {self.fast_start} positions are too close for the given step size {self.fast_step_size}."
|
||||
)
|
||||
if np.isclose(self.stepper_start, self.stepper_stop, atol=self.stepper_step_size):
|
||||
raise ValueError(
|
||||
f"Stepper stop {self.stepper_stop} and stepper start {self.stepper_start} positions are too close for the given step size {self.stepper_step_size}."
|
||||
)
|
||||
if self.settle_time < 0:
|
||||
raise ValueError(f"settle_time must be >= 0, got {self.settle_time}.")
|
||||
|
||||
def _compute_line_geometry(self):
|
||||
"""Velocity, PSO window and run-up / run-out of the fast axis."""
|
||||
line = self._line
|
||||
# the trigger hardware opens the shutter at the trigger and starts the gates this
|
||||
# much later (DDG1 -> DDG2 at the beamline, the PandA in the test setup)
|
||||
if self.shutter_delay is not None:
|
||||
line["shutter_open_delay"] = self.shutter_delay
|
||||
elif self.trigger_source == "ddg":
|
||||
line["shutter_open_delay"] = self.ddg1.get_shutter_to_open_delay()
|
||||
elif self.trigger_source == "panda":
|
||||
line["shutter_open_delay"] = self.panda.get_shutter_to_open_delay()
|
||||
else:
|
||||
line["shutter_open_delay"] = 0.0
|
||||
line["readout_time"] = self._readout_time()
|
||||
if not 0 <= line["readout_time"] < self.exp_time:
|
||||
raise ValueError(
|
||||
f"Readout time {line['readout_time']} s of the trigger hardware must be shorter"
|
||||
f" than exp_time {self.exp_time} s."
|
||||
)
|
||||
line["original_velocity"] = self.fast_axis.motor_velocity.get()
|
||||
line["acceleration"] = self.fast_axis.motor_acceleration.get()
|
||||
|
||||
velocity = self.fast_step_size / self.exp_time
|
||||
max_velocity = self.fast_axis.max_scan_velocity.get()
|
||||
if velocity > max_velocity:
|
||||
raise ValueError(
|
||||
f"Requested velocity of {velocity} (fast_step_size / exp_time) exceeds the"
|
||||
f" maximum scan velocity {max_velocity} configured for {self.fast_axis.name}."
|
||||
)
|
||||
if not line["acceleration"] or line["acceleration"] <= 0:
|
||||
raise ValueError(
|
||||
f"No acceleration configured for {self.fast_axis.name}; it is needed for the"
|
||||
" run-up distance of the line."
|
||||
)
|
||||
ramp_distance = velocity**2 / (2 * line["acceleration"])
|
||||
if self.minimal_run_up:
|
||||
# exposures start right after the ramp; the captured positions show the result
|
||||
margin = max(MIN_RUN_UP - ramp_distance, 0.0)
|
||||
else:
|
||||
margin = RAMP_MARGIN_FRACTION * ramp_distance + velocity * self.settle_time
|
||||
lead = velocity * line["shutter_open_delay"]
|
||||
# centre each gate (exp_time - readout_time) in its cell: same centres in both directions
|
||||
gap = velocity * line["readout_time"] / 2
|
||||
|
||||
line["target_velocity"] = velocity
|
||||
line["exposure_end"] = self.fast_start + self.frames_per_line * self.fast_step_size
|
||||
line["trigger_low"] = self.fast_start - lead + gap
|
||||
line["trigger_high"] = line["exposure_end"] + lead - gap
|
||||
line["line_start"] = line["trigger_low"] - ramp_distance - margin
|
||||
line["line_end"] = line["trigger_high"] + ramp_distance + margin
|
||||
|
||||
def _check_line_limits(self):
|
||||
"""The lines including run-up / run-out and the step axis range must fit the limits."""
|
||||
line = self._line
|
||||
name = self.fast_axis.name
|
||||
low, high = self.fast_axis.limits
|
||||
if low < high and (self.fast_start < low or self.fast_end > high):
|
||||
raise LimitError(
|
||||
f"Scan range of {name} {self.fast_start:.4f} .. {self.fast_end:.4f} is outside"
|
||||
f" the limits ({low}, {high})."
|
||||
)
|
||||
if low < high and (line["line_start"] < low or line["line_end"] > high):
|
||||
extra = self.fast_start - line["line_start"]
|
||||
if line["line_end"] > high:
|
||||
beyond = f"beyond the high limit {high} by {line['line_end'] - high:.4f}"
|
||||
else:
|
||||
beyond = f"beyond the low limit {low} by {low - line['line_start']:.4f}"
|
||||
if self.minimal_run_up:
|
||||
why = f"(acceleration {line['acceleration']:g} / s^2, minimal run-up)"
|
||||
else:
|
||||
why = (
|
||||
f"and settle (acceleration {line['acceleration']:g} / s^2, settle_time"
|
||||
f" {self.settle_time:g} s)"
|
||||
)
|
||||
raise LimitError(
|
||||
f"The scan range of {name} {self.fast_start:.4f} .. {self.fast_end:.4f} is"
|
||||
f" within the limits, but {name} needs {extra:.4f} extra travel on each side"
|
||||
f" to accelerate to the scan velocity {line['target_velocity']:g} / s"
|
||||
f" {why}. It would travel {line['line_start']:.4f} .."
|
||||
f" {line['line_end']:.4f}, {beyond}. Move the scan away from the limit or"
|
||||
" scan slower (larger exp_time or smaller fast_step_size)."
|
||||
)
|
||||
low, high = self.stepper_axis.limits
|
||||
stepper_range = sorted((self.stepper_start, self.stepper_stop))
|
||||
if low < high and (stepper_range[0] < low or stepper_range[1] > high):
|
||||
raise LimitError(
|
||||
f"{self.stepper_axis.name} range {stepper_range[0]:.4f} .. {stepper_range[1]:.4f}"
|
||||
f" is outside the limits ({low}, {high})."
|
||||
)
|
||||
|
||||
def _apply_shutter_delay(self):
|
||||
"""
|
||||
A shutter_delay argument must also reach DDG1, which times the gates itself (the
|
||||
trigger positions alone would not match it). Set before DDG1 stages, restored in
|
||||
_restore. The PandA gets the delay with configure_line.
|
||||
"""
|
||||
if self.trigger_source != "ddg" or self.shutter_delay is None:
|
||||
return
|
||||
current = self.ddg1.get_shutter_to_open_delay()
|
||||
if current != self.shutter_delay:
|
||||
self._ddg1_shutter_delay = current
|
||||
self.ddg1.set_shutter_to_open_delay(self.shutter_delay)
|
||||
|
||||
def _restore(self):
|
||||
"""Undo the scan settings; every step is tried even if an earlier one fails."""
|
||||
steps = []
|
||||
if self._ddg1_shutter_delay is not None:
|
||||
delay = self._ddg1_shutter_delay
|
||||
steps.append(lambda: self.ddg1.set_shutter_to_open_delay(delay))
|
||||
if self.trigger_source == "ddg":
|
||||
steps.append(lambda: self.ddg1.set_trigger(DDG_SINGLE_SHOT))
|
||||
elif self.trigger_source == "panda":
|
||||
steps.append(self.panda.disarm)
|
||||
if self._fast_axis_configured:
|
||||
if self._line.get("original_velocity") is not None:
|
||||
steps.append(
|
||||
lambda: self.fast_axis.motor_velocity.put(self._line["original_velocity"])
|
||||
)
|
||||
steps.append(lambda: self.fast_axis.wait_in_position.put(True))
|
||||
steps.append(self.fast_axis.disarm_line_trigger)
|
||||
errors = []
|
||||
for step in steps:
|
||||
try:
|
||||
step()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.error(f"aerotech_cont_grid: restore step failed: {exc}")
|
||||
errors.append(exc)
|
||||
if errors:
|
||||
raise errors[0]
|
||||
@@ -0,0 +1,220 @@
|
||||
# Aerotech stages (SAXS-TT): design and triggering
|
||||
|
||||
Developer notes for the Aerotech A3200 integration and the continuous grid scan
|
||||
`aerotech_cont_grid`. User commands are on the
|
||||
[user page](../user/saxs/aerotech_stages/aerotech_stages.md).
|
||||
|
||||
| Part | Code |
|
||||
|------|------|
|
||||
| Controller, motors, position capture | `csaxs_bec/devices/aerotech/a3200.py` |
|
||||
| Scan | `csaxs_bec/scans/scans_v4/aerotech_cont_grid.py` |
|
||||
| PandABox line trigger (test setup) | `csaxs_bec/devices/panda_box/panda_line_trigger.py` |
|
||||
| Simulation | `csaxs_bec/devices/sim/sim_a3200.py` |
|
||||
| Configs | `device_configs/sastt_aerotech.yaml`, `legacy/sastt_aerotech_panda.yaml`, `simulated_omny/simulated_sastt_aerotech.yaml` |
|
||||
| Hardware notes, wiring, channel table | `csaxs_bec/devices/aerotech/README.md` |
|
||||
|
||||
## Hardware and interface
|
||||
|
||||
Npaq-B-40B-40B/ULTRA drive rack, commanded over FireWire by the A3200 6.04.004
|
||||
software on the Windows PC `pc14643.psi.ch`. The Npaq Ethernet port is for Modbus
|
||||
I/O only, so BEC talks to the A3200 ASCII command interface on the PC (TCP port 8000,
|
||||
`CommandSetup = 0x00010004`: interface enabled and "Always Send EOS").
|
||||
|
||||
- One AeroBasic line per command. Reply: `%` (+ data) success, `!` invalid syntax,
|
||||
`#` task fault (`~LASTERROR` gives the text).
|
||||
- `~STATUS (X, ProgramPositionFeedback) (X, AxisStatus) ...` reads several items in
|
||||
one round trip.
|
||||
- Only asynchronous motion commands (`MOVEABS`, `HOMEASYNC`, `ABORT`); a blocking
|
||||
command like `HOME` would block the socket until it finishes.
|
||||
- Motion commands are never retried, status reads are retried once.
|
||||
- A second client may only use `~STATUS` in parallel; its controller commands would
|
||||
be refused while BEC's are running, unless it switches to its own task (`~TASK 3`).
|
||||
- BEC uses `$global[0..199]` for drive array reads and `$global[254]` as scratch
|
||||
variable (`GlobalDoubles` >= 255).
|
||||
|
||||
## Initialisation of the stages
|
||||
|
||||
!!! note "Current expectation"
|
||||
The user initialises the stages on the A3200 PC in Motion Composer: enable the
|
||||
axes, acknowledge faults, home. BEC does not do this at connect.
|
||||
|
||||
The motors refuse moves while the axis is not homed (`require_homed=True`). The
|
||||
methods exist in BEC (`enable()`, `disable()`, `home()`, `fault_ack()` on the motors,
|
||||
`describe()` and `axis_status()` on the controller) but are not part of any
|
||||
automatic procedure. UU follows U as gantry slave (`GantryMasterAxis = 3`) and is not
|
||||
a BEC device; check the slip ring cabling before the first rotation.
|
||||
|
||||
## Moves
|
||||
|
||||
`A3200Motor.move()` sends `MOVEABS` with the axis velocity, after setting the ramp
|
||||
rate (`acceleration`) on the controller. The move is complete when, after the end of
|
||||
the motion profile, the position error stays within `tolerance` for 0.1 s (timeout
|
||||
`in_position_timeout`, 2 s).
|
||||
|
||||
The controller's own In Position bit is not used: `InPositionDistance` of X is
|
||||
10 nm, which the stage reaches only after seconds or not at all (measured
|
||||
2026-10-29: 10-30 nm within 0.5 s, then creeping), so every move logged a timeout.
|
||||
|
||||
Velocities and acceleration of X: moves at 5 mm/s, ramp 500 mm/s^2 (decided value;
|
||||
1000 mm/s^2 left a speed drift in the lines at 20 mm/s). Scans may set up to
|
||||
`max_scan_velocity` (50 mm/s; the controller clamp is 200 mm/s) and restore the move
|
||||
velocity in `post_scan` and `on_exception`.
|
||||
|
||||
## Scan design
|
||||
|
||||
### Snake and line trigger
|
||||
|
||||
X runs continuously through each line, Y steps between lines, the lines alternate
|
||||
direction. The start of a line is triggered by the PSO output of X when it enters a
|
||||
position window:
|
||||
|
||||
```text
|
||||
trigger_low = fast_start - v * shutter_delay + v * readout_time / 2 (forward)
|
||||
trigger_high = exposure_end + v * shutter_delay - v * readout_time / 2 (backward)
|
||||
```
|
||||
|
||||
- **Window open on the far side** (`[trigger_low, beyond line_end]` forward). The axis
|
||||
never leaves the window during a line. With a closed window the exit coincided with
|
||||
the end of the exposures, and encoder noise at the exit edge started extra lines.
|
||||
Noise at the entry edge falls into the running line, which DDG and PandA ignore
|
||||
(typically one extra edge per line, logged at info level).
|
||||
- **Armed once** in `pre_scan` (one position reference); before each line only the
|
||||
range is switched (`PSOWINDOW RANGE`), while the axis is outside both windows.
|
||||
- **Shutter delay:** at the beamline DDG1 opens the shutter at the line trigger and
|
||||
triggers DDG2 `shutter_to_open_delay` later (8 ms, measured from the first-point
|
||||
deficit; `ddg1.set_shutter_to_open_delay()`). DDG2 then bursts `frames_per_line`
|
||||
gates with period `exp_time` and width `exp_time - readout_time`, so `exp_time` is the
|
||||
cell period and the readout is subtracted from the exposure. The trigger positions
|
||||
lead the first cell by `v * shutter_delay`, so the first gate starts at the first cell
|
||||
in both directions for any delay (no need for multiples of `exp_time`). A
|
||||
`shutter_delay` scan argument is set on DDG1 for the scan and restored afterwards.
|
||||
The PandA mimics this chain: shutter output at the edge, first gate
|
||||
`shutter_delay` later (device config, default 8 ms), same gate period and width.
|
||||
- **Readout gap centred:** each gate (`exp_time - readout_time`) is centred in its
|
||||
cell, so forward and backward lines expose the same intervals. Without it the
|
||||
directions differ by `fast_step_size * readout_time / exp_time`. The readout time
|
||||
comes from the trigger hardware (DDG2 `effective_readout_times()["ab"]`, PandA
|
||||
config `readout_time`, both 0.2 ms) and is stored in `scan_info.readout_time`.
|
||||
|
||||
### Run-up, settling and limits
|
||||
|
||||
Run-up / run-out per side: `2 * v^2 / (2 * acceleration) + v * settle_time`. In a
|
||||
snake the run-out of a line is the run-up of the next. `settle_time` stays 0.15 s: at
|
||||
20 mm/s and 500 mm/s^2 there is a ~10 Hz oscillation after the ramp that decays within
|
||||
~0.15 s; shorter values (0.08 / 0.04 / 0.02 / 0 s) gave backward minus forward offsets
|
||||
of -24 / +10 / +4.5 / -21 um (2026-10-29).
|
||||
|
||||
`prepare_scan` checks the whole travel range including run-up, and the Y range,
|
||||
against the soft limits before anything moves. The message separates the scan range
|
||||
from the run-up so a user can see why a scan inside the limits does not fit.
|
||||
|
||||
### Step during the run-out
|
||||
|
||||
As soon as X has left the exposure range of a line, the PSO window of the next line
|
||||
is set and Y starts its step; the next line starts when both axes have arrived. X
|
||||
does not wait for In Position at the turnarounds (`wait_in_position` off during the
|
||||
scan).
|
||||
|
||||
### Triggering and detectors
|
||||
|
||||
The scan is hardware triggered: MCS and detectors are armed once at stage for all
|
||||
`num_lines x frames_per_line` frames (`num_points` = lines, `frames_per_trigger` =
|
||||
frames per line).
|
||||
|
||||
| `trigger_source` | Line trigger | Per line check |
|
||||
|------------------|--------------|----------------|
|
||||
| `"ddg"` (beamline) | PSO -> DDG1 EXT, one burst of `frames_per_line` gates | MCS reached the line's last channel |
|
||||
| `"panda"` (test setup) | PSO -> PandABox TTLIN3, shutter pulse + gates | `wait_lines_done`: lines and pulses |
|
||||
| `None` | none (motion tests) | - |
|
||||
|
||||
`trigger_edge` of DDG1: `"rising"` for the opto output (J7-6/7, tested), `"falling"`
|
||||
for open collector / differential (active low, not tested).
|
||||
|
||||
### Errors reach the user as alarms
|
||||
|
||||
The scan's own checks raise `LimitError`, `ValueError` or `RuntimeError`, not
|
||||
`ScanAbortion`. The scan worker only logs a `ScanAbortion`, and the client then shows
|
||||
just "Scan ... was interrupted"; other exceptions raise an alarm with the message and
|
||||
still run `on_exception` (restore velocity, PSO off, trigger receiver disarmed).
|
||||
|
||||
## Position capture
|
||||
|
||||
`aerocapture` (`A3200PositionCapture`) latches the encoder counters of X, Z, Y, U at
|
||||
every detector gate: Npaq DATAACQ on UINT1 (J12), trigger 3 = rising TTL edge = gate
|
||||
start (the Npaq input is inverted compared with the A3200 help, measured). After each
|
||||
line the drive array is read back (~2600 values/s over the ASCII interface) and
|
||||
published as async data:
|
||||
|
||||
- `aerocapture_positions_enc<channel>`: position at the start of each gate,
|
||||
- `aerocapture_positions_enc<channel>_centre`: centre of each gate, `+ v * gate / 2`
|
||||
in the direction of motion for the moving fast axis.
|
||||
|
||||
The counter offset is calibrated in `pre_scan` with one software PSO fire, before any
|
||||
trigger receiver is armed. Positions are in the user coordinates of the BEC motor of
|
||||
each channel (its `sign` applied). DATAACQ sources: 0-5 local counters, 6-11 MXR
|
||||
channels 0-5 (max. 4 kHz with MXR sources).
|
||||
|
||||
At the end the scan sends statistics to the client: centre minus nominal cell
|
||||
centre per direction, backward minus forward offset with slope and peak-to-peak
|
||||
along the line, and line period with the motion and readout parts.
|
||||
|
||||
## PandABox as line trigger (test setup)
|
||||
|
||||
Without beamline timing, `legacy/sastt_aerotech_panda.yaml` adds `pandatrig`
|
||||
(`omny-panda.psi.ch`) as a DDG stand-in. It stays armed like the DDG, and every
|
||||
accepted line trigger edge produces:
|
||||
|
||||
| Output | Signal |
|
||||
|--------|--------|
|
||||
| TTLOUT2 "shutter" | from the trigger edge to the end of the last gate |
|
||||
| TTLOUT3 "detector" | n gates, width `exp_time - readout_time`, period `exp_time`, first one `shutter_delay` after the edge; also to Npaq J12 for the position capture |
|
||||
|
||||
Trigger path: LUT8 = TTLIN3 | BITS.A (software trigger); SRGATE1 arm gate; LUT7 =
|
||||
rising edge of LUT8 while armed and while no line is running -> PULSE3 (shutter),
|
||||
PULSE4 (gates). Edges during a running line are ignored, like on the DDG. Counters:
|
||||
COUNTER6 raw edges, COUNTER7 accepted lines, COUNTER8 gates. Layout
|
||||
`devices/panda_box/layouts/sastt_line_trigger.ini`; the device writes the wiring at
|
||||
connect.
|
||||
|
||||
```python
|
||||
dev.pandatrig.configure_line(n_pulses=100, exp_time=0.01, readout_time=0.002, shutter_delay=0.005)
|
||||
dev.pandatrig.arm()
|
||||
dev.pandatrig.soft_line_trigger() # or the PSO edge on TTLIN3
|
||||
dev.pandatrig.wait_lines_done(n_lines=1, timeout=5)
|
||||
dev.pandatrig.line_status() # triggers, lines, pulses, dropped since arm()
|
||||
```
|
||||
|
||||
PandA FPGA 4.1 PULSE block quirk: an ENABLE edge, a parameter write or a dropped
|
||||
trigger often makes it replay the previous pulse train without a trigger. The device
|
||||
keeps the PULSE blocks enabled, writes their parameters only when they change, waits
|
||||
until such a replay is over (both outputs low for 1 s), and the trigger path filters
|
||||
edges during a line, so no PULSE block ever drops a trigger.
|
||||
|
||||
## Pitfalls found during commissioning
|
||||
|
||||
- **No integer map keys in anything sent over Redis.** BEC's msgpack decoder rejects
|
||||
them (`strict_map_key`). An integer-keyed YAML map in a device config made the
|
||||
config load hang; an integer-keyed RPC return value made the scan wait forever for
|
||||
the call. Tests round-trip configs and RPC return values through the serializer.
|
||||
- **`USER_ACCESS` must contain every method the scan calls**, the scan server reaches
|
||||
devices only through RPC. A test checks the scan source against the lists.
|
||||
- **`USER_ACCESS` must not use names of BEC's `DeviceBase`** (e.g. `software_trigger`);
|
||||
the device server then refuses to load the device. A test checks this.
|
||||
- **Device code reloads with the config, scan code does not:** after a change in
|
||||
`a3200.py` or the PandA device reload the config; after a change in the scan
|
||||
restart the BEC services.
|
||||
|
||||
## Commissioning checklist
|
||||
|
||||
1. A3200 runs on the Windows PC; all axes enable and move in Motion Composer.
|
||||
2. Export the parameter file and keep a copy.
|
||||
3. Configuration Manager: `CommandSetup = 0x00010004`, `CommandPort = 8000`, reset
|
||||
the controller. Windows firewall: allow inbound TCP 8000 from the BEC host.
|
||||
4. `nc pc14643.psi.ch 8000`, send `~VERSION`, expect `%6.04...`.
|
||||
5. Host, axes, velocities and limits in `sastt_aerotech.yaml` follow the parameter
|
||||
file.
|
||||
6. Channels (`pso_input`, `pso_counts_divider`, `aerocapture.channels`): counter
|
||||
direction and scale match the axis (the calibration only fixes the offset).
|
||||
7. PSO output selection and polarity; PSO interlock defeated (`IOSetup` 0x80).
|
||||
8. Check one line with the scope: PSO edge at the trigger position, number of UINT1
|
||||
edges = frames per line, captured positions vs. requested positions.
|
||||
@@ -0,0 +1,286 @@
|
||||
# Aerotech stages (SAXS-TT)
|
||||
|
||||
The SAXS-TT sample stage is an Aerotech stack driven by an A3200 controller. BEC
|
||||
controls it for positioning and for fast continuous scans: the sample moves
|
||||
continuously along X while the detector takes one frame per cell, and the encoder
|
||||
positions are recorded for every frame.
|
||||
|
||||
| BEC device | Axis | Stage | Units | Use |
|
||||
|------------|------|-------|-------|-----|
|
||||
| `aerox` | X | ALS25020 linear stage | mm | horizontal, fast axis of the scans |
|
||||
| `aeroy` | Y | AVSI125 vertical lift | mm | vertical, step axis of the scans |
|
||||
| `aeroz` | Z | ATS20010 linear stage | mm | along the beam |
|
||||
| `aerorot` | U | ABRT-200 rotation stage | deg | rotation (UU follows as slave) |
|
||||
| `aerocapture` | - | position capture | - | encoder positions at each exposure |
|
||||
|
||||
## Before you start
|
||||
|
||||
!!! warning "Initialise the stages on the A3200 PC"
|
||||
BEC does not initialise the stages. Before using them in BEC, enable all axes,
|
||||
acknowledge any faults and home them in the Aerotech software (Motion Composer)
|
||||
on the A3200 PC `pc14643.psi.ch`. BEC refuses to move an axis that is not homed.
|
||||
|
||||
Load the device configuration in the BEC client:
|
||||
|
||||
```python
|
||||
bec.config.update_session_with_file(
|
||||
"/opt/bec_deployments/production/csaxs_bec/csaxs_bec/device_configs/sastt_aerotech.yaml"
|
||||
)
|
||||
```
|
||||
|
||||
Check the stages:
|
||||
|
||||
```python
|
||||
dev.aerox.controller.describe() # all axes: enabled, homed, moving, faults, position
|
||||
dev.aerox.status() # one axis in detail
|
||||
```
|
||||
|
||||
## Moving the stages
|
||||
|
||||
```python
|
||||
umv(dev.aerox, 143.25) # absolute move, waits until the axis has settled
|
||||
umvr(dev.aeroy, -0.5) # relative move
|
||||
dev.aerox.wm # position and limits
|
||||
dev.aerox.limits = [-50, 150]
|
||||
```
|
||||
|
||||
Normal moves of X run at 5 mm/s with an acceleration of 500 mm/s^2. A move is done
|
||||
when the position stays within the tolerance of the axis (X: 1 um, Y and Z: 5 um).
|
||||
The soft limits are checked before every move.
|
||||
|
||||
## Continuous grid scan: `aerotech_cont_grid`
|
||||
|
||||
X moves continuously through each line, Y steps between the lines, and the lines run
|
||||
as a snake (alternating direction). The start of each line is triggered in hardware
|
||||
by the X position, so the detector frames sit at the same positions in both
|
||||
directions.
|
||||
|
||||
```python
|
||||
scans.aerotech_cont_grid(
|
||||
dev.aerox, fast_start, fast_end, fast_step_size,
|
||||
dev.aeroy, stepper_start, stepper_stop, stepper_step_size,
|
||||
exp_time=..., relative=True,
|
||||
)
|
||||
```
|
||||
|
||||
| Argument | Meaning |
|
||||
|----------|---------|
|
||||
| `fast_start`, `fast_end` | first and last cell of a line along X |
|
||||
| `fast_step_size` | X distance travelled during one exposure (cell size) |
|
||||
| `stepper_start`, `stepper_stop`, `stepper_step_size` | Y range and line spacing |
|
||||
| `exp_time` | exposure time per cell in s; the X velocity is `fast_step_size / exp_time` |
|
||||
| `relative` | `True`: positions are offsets from the current X / Y position, and both axes return there after the scan |
|
||||
| `trigger_source` | `"ddg"` (default, beamline timing) |
|
||||
| `settle_time` | constant velocity time before the first exposure of a line, default 0.15 s (leave it) |
|
||||
| `shutter_delay` | time from the line trigger to the first exposure, for the shutter to open. Default: the value of DDG1 (8 ms); a given value is set on DDG1 for this scan only |
|
||||
| `minimal_run_up` | `True`: run-up only the acceleration distance (at least 10 um), no settling. Faster lines, but the exposures are less evenly spaced; use the captured positions. Default `False` |
|
||||
|
||||
The number of points per line is `ceil(|fast_end - fast_start| / fast_step_size) + 1`,
|
||||
the number of lines `ceil(|stepper_stop - stepper_start| / stepper_step_size) + 1`.
|
||||
The scan velocity can be up to 50 mm/s (`max_scan_velocity` of `aerox`). After the
|
||||
scan, also after an aborted one, X returns to its normal move velocity.
|
||||
|
||||
The client shows a progress bar (frames of the MCS card at the beamline, captured
|
||||
lines otherwise) and, at the end of the scan, a short summary of the positioning
|
||||
and the line times.
|
||||
|
||||
To watch the measured positions while the scan runs, open the live plot of the
|
||||
exposure centres (Y versus X, forward and backward lines in two colours, updated
|
||||
after each line):
|
||||
|
||||
```python
|
||||
csaxs.aerotech.plot() # shows the last scan, then follows the running and the next scans
|
||||
csaxs.aerotech.plot(scan_number=8494) # a finished scan
|
||||
csaxs.aerotech.stop_plot() # stop following (the plot stays)
|
||||
```
|
||||
|
||||
Every exposure is shown as a marker. Forward and backward lines alternate, so the two
|
||||
colours alternate in Y by one line spacing.
|
||||
|
||||
### Examples
|
||||
|
||||
Fields of view centred on the current position (`relative=True`), X fast, Y step:
|
||||
|
||||
```python
|
||||
# 0.8 mm x 1.2 mm (horizontal x vertical)
|
||||
scans.aerotech_cont_grid(dev.aerox, -0.4, 0.4, 0.1, dev.aeroy, -0.6, 0.6, 0.1, exp_time=0.005, relative=True) # quick: 100 um, 5 ms (20 mm/s), 9 x 13
|
||||
scans.aerotech_cont_grid(dev.aerox, -0.4, 0.4, 0.04, dev.aeroy, -0.6, 0.6, 0.04, exp_time=0.02, relative=True) # overview: 40 um, 20 ms (2 mm/s), 21 x 31
|
||||
scans.aerotech_cont_grid(dev.aerox, -0.4, 0.4, 0.01, dev.aeroy, -0.6, 0.6, 0.01, exp_time=0.02, relative=True) # high resolution: 10 um, 20 ms (0.5 mm/s), 81 x 121
|
||||
|
||||
# 2 mm x 2 mm
|
||||
scans.aerotech_cont_grid(dev.aerox, -1.0, 1.0, 0.1, dev.aeroy, -1.0, 1.0, 0.1, exp_time=0.005, relative=True) # quick: 21 x 21
|
||||
scans.aerotech_cont_grid(dev.aerox, -1.0, 1.0, 0.04, dev.aeroy, -1.0, 1.0, 0.04, exp_time=0.02, relative=True) # overview: 51 x 51
|
||||
|
||||
# 1 mm x 1 mm
|
||||
scans.aerotech_cont_grid(dev.aerox, -0.5, 0.5, 0.01, dev.aeroy, -0.5, 0.5, 0.01, exp_time=0.02, relative=True) # high resolution: 101 x 101
|
||||
|
||||
# 3.5 mm x 3.0 mm (largest field of view)
|
||||
scans.aerotech_cont_grid(dev.aerox, -1.75, 1.75, 0.1, dev.aeroy, -1.5, 1.5, 0.1, exp_time=0.005, relative=True) # quick: 36 x 31
|
||||
scans.aerotech_cont_grid(dev.aerox, -1.75, 1.75, 0.04, dev.aeroy, -1.5, 1.5, 0.04, exp_time=0.02, relative=True) # overview: 89 x 76
|
||||
scans.aerotech_cont_grid(dev.aerox, -1.75, 1.75, 0.01, dev.aeroy, -1.5, 1.5, 0.01, exp_time=0.02, relative=True) # high resolution: 351 x 301
|
||||
```
|
||||
|
||||
### Run-up and soft limits
|
||||
|
||||
To pass through the field of view at constant velocity, X travels beyond it on both
|
||||
sides: twice the acceleration distance `v^2 / (2 * 500 mm/s^2)` plus `v * 0.15 s` to
|
||||
settle. The run-up per side is
|
||||
|
||||
| Scan velocity | 0.5 mm/s | 2 mm/s | 20 mm/s |
|
||||
|---------------|----------|--------|---------|
|
||||
| Run-up per side | 0.08 mm | 0.31 mm | 3.8 mm |
|
||||
|
||||
Before anything moves, the scan checks the whole range including the run-up against
|
||||
the soft limits. If it does not fit, the scan stops with a message that says by how
|
||||
much and on which side, for example
|
||||
|
||||
```text
|
||||
The scan range of aerox 146.5000 .. 150.0000 is within the limits, but aerox needs
|
||||
3.8400 extra travel on each side to accelerate to the scan velocity 20 / s and settle
|
||||
...
|
||||
```
|
||||
|
||||
Move the field of view away from the limit, or scan slower.
|
||||
|
||||
### Data
|
||||
|
||||
Besides the detector data, each scan file contains, per frame:
|
||||
|
||||
| Data | Content |
|
||||
|------|---------|
|
||||
| `aerocapture_positions_enc6`, `aerocapture_positions_enc2` | X and Y at the start of each exposure |
|
||||
| `aerocapture_positions_enc6_centre`, `aerocapture_positions_enc2_centre` | X and Y at the centre of each exposure |
|
||||
|
||||
They are stored one line after the other in the order of the measurement, so the
|
||||
backward lines run from high to low X. The positions are in the coordinates of
|
||||
`aerox` / `aeroy`. Use the centre positions to place the frames; they are measured,
|
||||
not nominal. The nominal positions are in the scan metadata (`computed_positions`).
|
||||
|
||||
At the end of the scan the client shows a short summary: deviation of the measured
|
||||
centres from the nominal cells, backward minus forward offset, and the line times.
|
||||
|
||||
## Timing and positioning
|
||||
|
||||
Measured 2026-10-29 with the PandABox as trigger (readout 0.2 ms), X acceleration
|
||||
500 mm/s^2, X centred at 123.25, and the same nine scans in both run-up modes:
|
||||
scans 8486-8494 with the default run-up, 8501-8509 with `minimal_run_up=True`.
|
||||
|
||||
!!! note "Without shutter delay"
|
||||
In these scans the first exposure of a line started at the line trigger. At the
|
||||
beamline DDG1 opens the shutter at the trigger and starts the exposures 8 ms later,
|
||||
and the trigger comes correspondingly earlier on the line. These 8 ms give X more
|
||||
time to settle after the ramp, so the minimal run-up results at the beamline should
|
||||
be better than shown here.
|
||||
The target resolution is about 20 um; the positioning accuracy should be well below
|
||||
that.
|
||||
|
||||
Terms used below (positions in um):
|
||||
|
||||
- **rms:** scatter of the measured exposure centres around the nominal cells.
|
||||
- **bwd - fwd:** offset between neighbouring backward and forward lines, with its
|
||||
slope along X and its peak-to-peak over the line. A constant offset only shifts the
|
||||
field of view; a slope would distort it.
|
||||
- **Line period:** time from the start of one line to the start of the next.
|
||||
- **Positioning overhead:** line period minus the exposure per line (points times
|
||||
`exp_time`), per line and as a fraction of the scan time. It contains the run-up and
|
||||
run-out, the turnaround, the Y step, and the readout of the captured positions.
|
||||
|
||||
### Default run-up
|
||||
|
||||
| Field of view (mm) | Step / exposure (speed) | Points x lines | rms | bwd - fwd | slope (um/mm) | peak-to-peak | Line period | Exposure per line | Positioning overhead per line | Scan time |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 0.8 x 1.2 | 100 um / 5 ms (20 mm/s) | 9 x 13 | 2.9 | +1.2 | +3.4 | 11.4 | 0.79 s | 0.04 s | 0.74 s (94 %) | 0.2 min |
|
||||
| 0.8 x 1.2 | 40 um / 20 ms (2 mm/s) | 21 x 31 | 1.0 | +3.6 | +0.2 | 2.9 | 1.03 s | 0.42 s | 0.61 s (59 %) | 0.6 min |
|
||||
| 0.8 x 1.2 | 10 um / 20 ms (0.5 mm/s) | 81 x 121 | 0.2 | +0.1 | 0.0 | 0.8 | 2.21 s | 1.62 s | 0.59 s (27 %) | 4.5 min |
|
||||
| 2 x 2 | 100 um / 5 ms (20 mm/s) | 21 x 21 | 5.6 | +2.3 | +0.2 | 25.6 | 0.83 s | 0.10 s | 0.72 s (87 %) | 0.4 min |
|
||||
| 2 x 2 | 40 um / 20 ms (2 mm/s) | 51 x 51 | 0.6 | +2.8 | 0.0 | 2.4 | 1.64 s | 1.02 s | 0.62 s (38 %) | 1.5 min |
|
||||
| 1 x 1 | 10 um / 20 ms (0.5 mm/s) | 101 x 101 | 0.2 | 0.0 | 0.0 | 0.8 | 2.61 s | 2.02 s | 0.59 s (23 %) | 4.5 min |
|
||||
| 3.5 x 3.0 | 100 um / 5 ms (20 mm/s) | 36 x 31 | 5.7 | +0.8 | +0.1 | 30.6 | 0.91 s | 0.18 s | 0.73 s (80 %) | 0.6 min |
|
||||
| 3.5 x 3.0 | 40 um / 20 ms (2 mm/s) | 89 x 76 | 0.5 | +2.8 | 0.0 | 2.9 | 2.40 s | 1.78 s | 0.62 s (26 %) | 3.1 min |
|
||||
| 3.5 x 3.0 | 10 um / 20 ms (0.5 mm/s) | 351 x 301 | 0.1 | -0.7 | 0.0 | 0.9 | 7.75 s | 7.02 s | 0.73 s (9 %) | 38.9 min |
|
||||
|
||||
- **Positioning:** at 0.5 and 2 mm/s the exposure centres are within about 1 um
|
||||
rms of the nominal cells, and the snake is symmetric (no slope). A constant
|
||||
backward minus forward offset of up to ~4 um remains; it only shifts the field of
|
||||
view. At 20 mm/s a ~10 Hz oscillation of X after the acceleration ramp reaches into
|
||||
the line: up to 25-30 um peak-to-peak on the larger fields. That is small compared
|
||||
with the 100 um cells of these quick scans, but above the 20 um resolution target.
|
||||
- **Overhead:** about 0.6-0.75 s per line at every speed. It dominates the quick
|
||||
scans (80-94 % at 20 mm/s) and is small for the long high resolution lines.
|
||||
|
||||
### Minimal run-up (`minimal_run_up=True`)
|
||||
|
||||
With the minimal run-up X only accelerates to the scan velocity before the first
|
||||
exposure (at least 10 um), without the settling distance. The exposures are then
|
||||
less evenly spaced, which is acceptable if the data analysis uses the measured
|
||||
positions.
|
||||
|
||||
| Field of view (mm) | Step / exposure (speed) | Line period default -> minimal | Saved per line | Scan time default -> minimal | rms default -> minimal | bwd - fwd default -> minimal | max deviation default -> minimal |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 0.8 x 1.2 | 100 um / 5 ms (20 mm/s) | 0.78 -> 0.61 s | 0.17 s (22 %) | 0.2 -> 0.2 min | 2.9 -> 7.0 | +1.2 -> +5.6 | 5.8 -> 17.1 |
|
||||
| 0.8 x 1.2 | 40 um / 20 ms (2 mm/s) | 1.02 -> 0.88 s | 0.15 s (14 %) | 0.6 -> 0.5 min | 1.0 -> 3.1 | +3.6 -> +13.3 | 4.6 -> 15.9 |
|
||||
| 0.8 x 1.2 | 10 um / 20 ms (0.5 mm/s) | 2.20 -> 2.09 s | 0.11 s (5 %) | 4.5 -> 4.3 min | 0.2 -> 1.1 | +0.1 -> -8.9 | 1.0 -> 5.5 |
|
||||
| 2 x 2 | 100 um / 5 ms (20 mm/s) | 0.84 -> 0.65 s | 0.19 s (22 %) | 0.4 -> 0.3 min | 5.6 -> 10.0 | +2.3 -> -11.1 | 15.6 -> 24.7 |
|
||||
| 2 x 2 | 40 um / 20 ms (2 mm/s) | 1.63 -> 1.48 s | 0.16 s (10 %) | 1.5 -> 1.3 min | 0.6 -> 2.1 | +2.8 -> +13.6 | 4.5 -> 15.9 |
|
||||
| 1 x 1 | 10 um / 20 ms (0.5 mm/s) | 2.60 -> 2.50 s | 0.10 s (4 %) | 4.5 -> 4.3 min | 0.2 -> 1.0 | 0.0 -> -9.1 | 1.1 -> 5.4 |
|
||||
| 3.5 x 3.0 | 100 um / 5 ms (20 mm/s) | 0.91 -> 0.74 s | 0.17 s (19 %) | 0.6 -> 0.5 min | 5.8 -> 14.0 | +0.8 -> -42.9 | 17.5 -> 44.9 |
|
||||
| 3.5 x 3.0 | 40 um / 20 ms (2 mm/s) | 2.39 -> 2.23 s | 0.15 s (6 %) | 3.1 -> 2.9 min | 0.5 -> 1.7 | +2.8 -> +12.8 | 3.9 -> 16.6 |
|
||||
| 3.5 x 3.0 | 10 um / 20 ms (0.5 mm/s) | 7.75 -> 7.55 s | 0.19 s (2 %) | 38.9 -> 38.0 min | 0.1 -> 0.6 | -0.7 -> -10.2 | 0.8 -> 5.6 |
|
||||
|
||||
- **Time:** 0.10-0.19 s less per line, 19-22 % of the quick 20 mm/s scans, 6-14 %
|
||||
at 2 mm/s, 2-5 % at 0.5 mm/s. About half of what the shorter travel alone would
|
||||
give: with the default run-up the Y step is hidden in the run-out of X; with the
|
||||
minimal run-up X turns around almost at once and waits for Y.
|
||||
- **Positions, 0.5 and 2 mm/s:** the first exposure of each line is still at its
|
||||
nominal position (the line trigger is a position), but within the next 2-3 cells
|
||||
the exposures jump to a constant offset, then stay evenly spaced along the line.
|
||||
This is the velocity transient of X after the ramp: the gates are timed, so a
|
||||
velocity below (2 mm/s) or above (0.5 mm/s) the scan velocity right after the
|
||||
trigger shifts all later exposures. The offset has opposite signs in the two
|
||||
directions, so backward and forward lines are 9-14 um apart, without a slope.
|
||||
- **Positions, 20 mm/s:** the ~10 Hz oscillation after the ramp is now in the whole
|
||||
line: up to 25 um deviation on 2 mm and 45 um on 3.5 mm fields, with backward
|
||||
minus forward offsets up to 43 um. Well above the 20 um resolution target.
|
||||
- **For the data analysis:** use the measured exposure centres, not the nominal
|
||||
grid. In the first 2-3 cells of each line the cells are compressed or stretched,
|
||||
and the centre estimate (start of the gate plus half a gate at the scan velocity)
|
||||
is less accurate there, since X is not yet at constant velocity.
|
||||
|
||||
Measured minus nominal X centre along the line, all lines thin, the mean bold
|
||||
(left: default run-up, right: minimal run-up):
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Measured exposure centres of the first cells of the first lines against the nominal
|
||||
cell centres (crosses), forward lines blue, backward lines orange:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Where the time per line goes
|
||||
|
||||
Split of the line period, measured by the scan (`motion` = X through the line and the
|
||||
trigger check, `between lines` = waiting for Y and switching the trigger window):
|
||||
|
||||
| Case | Run-up | Line period | Motion | Capture readout | Between lines |
|
||||
|---|---|---|---|---|---|
|
||||
| 0.8 mm, 20 mm/s | default -> minimal | 0.77 -> 0.58 s | 0.60 -> 0.29 s | 0.03 s | 0.14 -> 0.26 s |
|
||||
| 0.8 mm, 2 mm/s | default -> minimal | 1.03 -> 0.88 s | 0.87 -> 0.62 s | 0.04-0.05 s | 0.12 -> 0.21 s |
|
||||
| 2 x 2 mm, 2 mm/s | default -> minimal | 1.64 -> 1.48 s | 1.47 -> 1.21 s | 0.05-0.06 s | 0.12 -> 0.21 s |
|
||||
|
||||
The next levers to reduce the overhead:
|
||||
|
||||
- **Y step:** Y moves with 10 mm/s^2, so a 100 um step takes ~0.2 s, plus a 0.1 s
|
||||
hold within the tolerance before the next line starts.
|
||||
- **Software latency:** roughly 0.15-0.2 s per line in the motion phase for the end
|
||||
of the move, the trigger check and the communication.
|
||||
- **Capture readout:** 0.03-0.3 s per line, growing with the points per line.
|
||||
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 61 KiB |
@@ -8,6 +8,11 @@ Welcome to the SAXS section of the cSAXS beamline.
|
||||
|
||||
Setup of online radial integration on Jungfraujoch.
|
||||
|
||||
- { width="32" height="32" .middle .card-icon } **[Aerotech stages](aerotech_stages/aerotech_stages.md)**
|
||||
{ .card-heading }
|
||||
|
||||
SAXS-TT sample stages and continuous grid scans.
|
||||
|
||||
</div>
|
||||
|
||||
## Reference documents
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from csaxs_bec.bec_ipython_client.plugins.cSAXS.aerotech import (
|
||||
_column_order,
|
||||
_lines_from_file,
|
||||
cSAXSAerotech,
|
||||
)
|
||||
|
||||
X0 = 100.0
|
||||
|
||||
|
||||
def _snake(n_lines=4, n_points=5, step=0.01):
|
||||
"""Exposure centres per line in measurement order (backward lines reversed)."""
|
||||
cells = X0 + step * np.arange(n_points)
|
||||
lines = []
|
||||
for i in range(n_lines):
|
||||
x = cells if i % 2 == 0 else cells[::-1]
|
||||
lines.append((x.copy(), np.full(n_points, 0.1 * i)))
|
||||
return lines
|
||||
|
||||
|
||||
def _client(windows=None):
|
||||
gui = mock.MagicMock()
|
||||
gui.windows = windows if windows is not None else {}
|
||||
gui.new.return_value.widget_map.return_value = {}
|
||||
client = mock.MagicMock(gui=gui)
|
||||
client.connector.get.return_value = None
|
||||
return client
|
||||
|
||||
|
||||
def _waveform(client):
|
||||
return client.gui.new.return_value.new.return_value
|
||||
|
||||
|
||||
def test_column_order_runs_up_and_down_the_columns_with_increasing_x():
|
||||
lines = _snake()
|
||||
x, y = _column_order(lines[0::2], backward=False)
|
||||
assert len(x) == 2 * 5
|
||||
assert np.all(np.diff(x) >= 0)
|
||||
# column 0 upwards (lines 0, 2), column 1 downwards (lines 2, 0)
|
||||
np.testing.assert_allclose(y[:4], [0.0, 0.2, 0.2, 0.0])
|
||||
xb, _ = _column_order(lines[1::2], backward=True)
|
||||
assert np.all(np.diff(xb) >= 0)
|
||||
|
||||
|
||||
def test_lines_from_file(tmp_path):
|
||||
lines = _snake()
|
||||
path = tmp_path / "scan.h5"
|
||||
with h5py.File(path, "w") as f:
|
||||
f["entry/collection/metadata/bec/frames_per_trigger"] = 5
|
||||
cap = f.create_group("entry/collection/devices/aerocapture")
|
||||
cap["aerocapture_positions_enc6_centre/value"] = np.concatenate([l[0] for l in lines])
|
||||
cap["aerocapture_positions_enc2_centre/value"] = np.concatenate([l[1] for l in lines])
|
||||
loaded = _lines_from_file(str(path))
|
||||
assert len(loaded) == 4
|
||||
np.testing.assert_allclose(loaded[1][0], lines[1][0])
|
||||
|
||||
|
||||
def test_plot_history_draws_forward_and_backward_curves():
|
||||
client = _client()
|
||||
waveform = _waveform(client)
|
||||
forward, backward = mock.MagicMock(), mock.MagicMock()
|
||||
waveform.plot.side_effect = [forward, backward]
|
||||
tool = cSAXSAerotech(client)
|
||||
with mock.patch.object(tool, "_history_lines", return_value=_snake()):
|
||||
assert tool.plot(scan_number=8494) is waveform
|
||||
|
||||
client.gui.new.assert_called_once_with("aerotech", timeout=20)
|
||||
client.gui.new.return_value.new.assert_called_once_with(
|
||||
"Waveform", object_name="aerotech_positions", timeout=20
|
||||
)
|
||||
assert waveform.lock_aspect_ratio is True
|
||||
for curve in (forward, backward):
|
||||
x, y = curve.set_data.call_args.args
|
||||
# markers only: a NaN after every point, X finite and increasing
|
||||
assert len(x) == 20 and np.all(np.diff(x) >= 0) and np.isfinite(x).all()
|
||||
assert np.isfinite(y[0::2]).all() and np.isnan(y[1::2]).all()
|
||||
client.connector.register.assert_not_called() # history: no live subscription
|
||||
|
||||
|
||||
def test_plot_reuses_open_window_and_plot():
|
||||
window = mock.MagicMock()
|
||||
existing = mock.MagicMock()
|
||||
window.widget_map.return_value = {"aerotech_positions": existing}
|
||||
client = _client(windows={"aerotech": window})
|
||||
tool = cSAXSAerotech(client)
|
||||
with mock.patch.object(tool, "_history_lines", return_value=_snake()):
|
||||
assert tool.plot(scan_number=1) is existing
|
||||
client.gui.new.assert_not_called()
|
||||
window.new.assert_not_called()
|
||||
existing.clear_all.assert_called_once_with()
|
||||
|
||||
|
||||
def test_plot_without_gui_raises():
|
||||
client = mock.MagicMock(spec=["device_manager"])
|
||||
with pytest.raises(RuntimeError, match="No BEC GUI"):
|
||||
cSAXSAerotech(client).plot()
|
||||
|
||||
|
||||
def test_live_follows_an_aerotech_scan_line_by_line():
|
||||
client = _client()
|
||||
waveform = _waveform(client)
|
||||
forward, backward = mock.MagicMock(), mock.MagicMock()
|
||||
waveform.plot.side_effect = [forward, backward]
|
||||
client.history.__getitem__.side_effect = IndexError # no scan yet
|
||||
tool = cSAXSAerotech(client)
|
||||
tool.plot()
|
||||
live = tool._live
|
||||
status = SimpleNamespace(
|
||||
status="open", scan_id="abc", scan_name="aerotech_cont_grid", scan_number=1
|
||||
)
|
||||
live._on_scan_status(SimpleNamespace(value=status))
|
||||
topic = client.connector.register.call_args_list[-1]
|
||||
assert topic.kwargs["from_start"] is True
|
||||
|
||||
for x, y in _snake(n_lines=2):
|
||||
signals = {
|
||||
"aerocapture_positions_enc6_centre": {"value": x},
|
||||
"aerocapture_positions_enc2_centre": {"value": y},
|
||||
}
|
||||
live._on_line({"data": SimpleNamespace(signals=signals)})
|
||||
deadline = time.time() + 3
|
||||
while time.time() < deadline and not backward.set_data.call_args_list:
|
||||
time.sleep(0.05)
|
||||
tool.stop_plot()
|
||||
|
||||
x, _ = backward.set_data.call_args.args
|
||||
assert len(x) == 2 * 5
|
||||
client.connector.unregister.assert_called()
|
||||
|
||||
|
||||
def test_live_ignores_other_scans():
|
||||
client = _client()
|
||||
client.history.__getitem__.side_effect = IndexError
|
||||
tool = cSAXSAerotech(client)
|
||||
tool.plot()
|
||||
registered = client.connector.register.call_count
|
||||
status = SimpleNamespace(status="open", scan_id="x", scan_name="line_scan", scan_number=2)
|
||||
tool._live._on_scan_status(SimpleNamespace(value=status))
|
||||
assert client.connector.register.call_count == registered
|
||||
tool.stop_plot()
|
||||
|
||||
|
||||
def test_live_stops_once_when_the_plot_is_closed():
|
||||
client = _client()
|
||||
waveform = _waveform(client)
|
||||
forward, backward = mock.MagicMock(), mock.MagicMock()
|
||||
forward.set_data.side_effect = RuntimeError("widget has been deleted")
|
||||
waveform.plot.side_effect = [forward, backward]
|
||||
client.history.__getitem__.side_effect = IndexError
|
||||
tool = cSAXSAerotech(client)
|
||||
with mock.patch(
|
||||
"csaxs_bec.bec_ipython_client.plugins.cSAXS.aerotech.logger"
|
||||
) as logger:
|
||||
tool.plot()
|
||||
live = tool._live
|
||||
live._set_lines(_snake(n_lines=2))
|
||||
live._worker.join(timeout=3)
|
||||
assert not live._worker.is_alive()
|
||||
assert tool._live is None
|
||||
assert logger.warning.call_count == 1
|
||||
client.connector.unregister.assert_called()
|
||||
@@ -0,0 +1,515 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from bec_server.device_server.tests.utils import DMMock
|
||||
from ophyd.utils import LimitError
|
||||
from ophyd_devices.tests.utils import SocketMock
|
||||
|
||||
from csaxs_bec.devices.aerotech.a3200 import (
|
||||
A3200CommunicationError,
|
||||
A3200Controller,
|
||||
A3200Error,
|
||||
A3200Motor,
|
||||
decode_axis_fault,
|
||||
)
|
||||
from csaxs_bec.devices.sim.sim_a3200 import SimA3200Motor, SimA3200PositionCapture, SimA3200State
|
||||
from csaxs_bec.devices.sim.sim_socket import SimStateRegistry
|
||||
|
||||
_HOST = "test-a3200.psi.ch"
|
||||
_PORT = 8000
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_controllers():
|
||||
A3200Controller._reset_controller()
|
||||
SimStateRegistry.reset()
|
||||
yield
|
||||
SimStateRegistry.reset()
|
||||
A3200Controller._reset_controller()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_motor():
|
||||
motor = A3200Motor(
|
||||
"X", name="aerox", host=_HOST, port=_PORT, socket_cls=SocketMock, device_manager=DMMock()
|
||||
)
|
||||
motor.controller.on()
|
||||
motor.controller.sock.flush_buffer()
|
||||
yield motor
|
||||
motor.controller.off()
|
||||
|
||||
|
||||
def _sim_motor(axis_Id="X", axis_nr=0, **kwargs):
|
||||
kwargs.setdefault("velocity", 1000.0)
|
||||
kwargs.setdefault("sim_home_velocity", 1000.0)
|
||||
motor = SimA3200Motor(
|
||||
axis_Id,
|
||||
name=f"aero{axis_Id.lower()}",
|
||||
axis_nr=axis_nr,
|
||||
host=_HOST,
|
||||
port=_PORT,
|
||||
device_manager=DMMock(),
|
||||
**kwargs,
|
||||
)
|
||||
motor.wait_for_connection()
|
||||
return motor
|
||||
|
||||
|
||||
# --- protocol (SocketMock) ---------------------------------------------------
|
||||
|
||||
|
||||
def test_command_returns_data_after_ack(mock_motor):
|
||||
mock_motor.controller.sock.buffer_recv = [b"%6.04.004.0016\n"]
|
||||
assert mock_motor.controller.get_version() == "6.04.004.0016"
|
||||
assert mock_motor.controller.sock.buffer_put == [b"~VERSION\n"]
|
||||
|
||||
|
||||
def test_reply_split_over_several_receives(mock_motor):
|
||||
mock_motor.controller.sock.buffer_recv = [b"%1.2", b"5 4194", b"305\n"]
|
||||
assert mock_motor.controller.get_status_items([("X", "A"), ("X", "B")]) == [1.25, 4194305.0]
|
||||
|
||||
|
||||
def test_two_replies_in_one_receive_are_kept_apart(mock_motor):
|
||||
mock_motor.controller.sock.buffer_recv = [b"%\n%7.5\n"]
|
||||
mock_motor.controller.enable_axis("X")
|
||||
assert mock_motor.controller.get_position("X") == 7.5
|
||||
|
||||
|
||||
def test_fault_reply_raises_with_last_error(mock_motor):
|
||||
mock_motor.controller.sock.buffer_recv = [b"#\n", b"%The axis is not enabled.\n"]
|
||||
with pytest.raises(A3200Error, match="The axis is not enabled."):
|
||||
mock_motor.controller.command("HOME X")
|
||||
assert mock_motor.controller.sock.buffer_put == [b"HOME X\n", b"~LASTERROR\n"]
|
||||
|
||||
|
||||
def test_invalid_reply_raises(mock_motor):
|
||||
mock_motor.controller.sock.buffer_recv = [b"!\n"]
|
||||
with pytest.raises(A3200Error, match="invalid"):
|
||||
mock_motor.controller.command("ENABLE")
|
||||
|
||||
|
||||
def test_motion_command_is_not_retried(mock_motor):
|
||||
mock_motor.controller.sock.buffer_recv = [b"#\n", b"%fault\n", b"%\n", b"%\n"]
|
||||
with pytest.raises(A3200Error):
|
||||
mock_motor.controller.move_absolute("X", 1.0, 2.0)
|
||||
assert mock_motor.controller.sock.buffer_put == [
|
||||
b"MOVEABS X 1.000000 2.000000\n",
|
||||
b"~LASTERROR\n",
|
||||
]
|
||||
|
||||
|
||||
def test_missing_eos_times_out(mock_motor):
|
||||
mock_motor.controller.sock.buffer_recv = [b"%"]
|
||||
with pytest.raises(A3200CommunicationError, match="Always Send EOS"):
|
||||
mock_motor.controller.socket_get(timeout=0.05)
|
||||
|
||||
|
||||
def test_status_query_format(mock_motor):
|
||||
mock_motor.controller.sock.buffer_recv = [b"%1.5 4194305 1 0\n"]
|
||||
status = mock_motor.controller.axis_status("X")
|
||||
assert mock_motor.controller.sock.buffer_put == [
|
||||
b"~STATUS (X, ProgramPositionFeedback) (X, AxisStatus) (X, DriveStatus) (X, AxisFault)\n"
|
||||
]
|
||||
assert status["position"] == 1.5
|
||||
assert status["enabled"] and status["homed"]
|
||||
assert not status["moving"]
|
||||
assert status["faults"] == []
|
||||
|
||||
|
||||
def test_decode_axis_fault():
|
||||
assert decode_axis_fault(0) == []
|
||||
assert decode_axis_fault((1 << 0) | (1 << 2) | (1 << 11)) == [
|
||||
"PositionError",
|
||||
"CwEOTLimit",
|
||||
"EstopFault",
|
||||
]
|
||||
|
||||
|
||||
# --- simulated controller ----------------------------------------------------
|
||||
|
||||
|
||||
def test_sim_move_and_readback():
|
||||
motor = _sim_motor(sim_initial_position=1.0)
|
||||
assert motor.readback.get() == pytest.approx(1.0)
|
||||
motor.move(5.0, wait=True)
|
||||
assert motor.readback.get() == pytest.approx(5.0)
|
||||
assert not motor.motor_is_moving.get()
|
||||
|
||||
|
||||
def test_sim_sign_is_applied():
|
||||
motor = _sim_motor(sign=-1, sim_initial_position=2.0)
|
||||
assert motor.readback.get() == pytest.approx(2.0)
|
||||
motor.move(-3.0, wait=True)
|
||||
assert motor.readback.get() == pytest.approx(-3.0)
|
||||
state = SimStateRegistry.get(SimA3200State, _HOST, _PORT)
|
||||
assert state.axis("X").position() == pytest.approx(3.0)
|
||||
|
||||
|
||||
def test_sim_move_refused_when_disabled():
|
||||
motor = _sim_motor(sim_enabled=False)
|
||||
with pytest.raises(A3200Error, match="not enabled"):
|
||||
motor.move(1.0, wait=True)
|
||||
motor.enable()
|
||||
motor.move(1.0, wait=True)
|
||||
assert motor.readback.get() == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_sim_move_refused_when_not_homed():
|
||||
motor = _sim_motor(sim_homed=False, sim_initial_position=3.0)
|
||||
with pytest.raises(A3200Error, match="not homed"):
|
||||
motor.move(1.0, wait=True)
|
||||
motor.home()
|
||||
assert "[PUT]: ABORT X" not in motor.controller.command_history
|
||||
assert motor.status()["homed"]
|
||||
assert motor.readback.get() == pytest.approx(0.0)
|
||||
motor.move(1.0, wait=True)
|
||||
assert motor.readback.get() == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_sim_unhomed_move_allowed_if_configured():
|
||||
motor = _sim_motor(sim_homed=False, require_homed=False)
|
||||
motor.move(2.0, wait=True)
|
||||
assert motor.readback.get() == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_sim_fault_blocks_move_until_acknowledged():
|
||||
motor = _sim_motor()
|
||||
SimStateRegistry.get(SimA3200State, _HOST, _PORT).axis("X").fault = 1 << 2
|
||||
with pytest.raises(A3200Error, match="CwEOTLimit"):
|
||||
motor.move(1.0, wait=True)
|
||||
motor.fault_ack()
|
||||
motor.move(1.0, wait=True)
|
||||
assert motor.readback.get() == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_sim_soft_limits():
|
||||
motor = _sim_motor(limits=[-1, 1])
|
||||
with pytest.raises(LimitError):
|
||||
motor.move(2.0, wait=True)
|
||||
|
||||
|
||||
def test_sim_stop_aborts_motion():
|
||||
motor = _sim_motor(velocity=1.0)
|
||||
status = motor.move(10.0, wait=False)
|
||||
motor.stop()
|
||||
with pytest.raises(Exception):
|
||||
status.wait(timeout=2)
|
||||
assert motor.readback.get() < 1.0
|
||||
assert not motor.motor_is_moving.get()
|
||||
|
||||
|
||||
def test_sim_two_axes_share_one_controller():
|
||||
x = _sim_motor("X", axis_nr=0)
|
||||
y = _sim_motor("Y", axis_nr=1)
|
||||
assert x.controller is y.controller
|
||||
x.move(1.0, wait=True)
|
||||
y.move(2.0, wait=True)
|
||||
assert x.readback.get() == pytest.approx(1.0)
|
||||
assert y.readback.get() == pytest.approx(2.0)
|
||||
x.controller.describe()
|
||||
|
||||
|
||||
# --- line trigger and position capture (simulated controller) ----------------
|
||||
|
||||
|
||||
def _sim_state():
|
||||
return SimStateRegistry.get(SimA3200State, _HOST, _PORT)
|
||||
|
||||
|
||||
def _capture_setup():
|
||||
# SAXS-TT numbering: X sine encoder (MXR channel 0, emulated quadrature to PSO input 6),
|
||||
# Y square wave encoder (local encoder counter 2)
|
||||
x = _sim_motor("X", axis_nr=0, pso_input=6, sim_counts_per_unit=20000, sim_counter_offset=777)
|
||||
y = _sim_motor("Y", axis_nr=2, pso_input=2, sim_initial_position=0.3, sim_counter_offset=-55)
|
||||
capture = SimA3200PositionCapture(
|
||||
name="aerocapture",
|
||||
host=_HOST,
|
||||
port=_PORT,
|
||||
channels={6: "X", 2: "Y"},
|
||||
device_manager=DMMock(),
|
||||
)
|
||||
capture.wait_for_connection()
|
||||
return x, y, capture
|
||||
|
||||
|
||||
def test_counts_per_unit_is_read_once():
|
||||
x, _, _ = _capture_setup()
|
||||
assert x.controller.counts_per_unit("X") == 20000
|
||||
x.controller.command_history.clear()
|
||||
assert x.controller.counts_per_unit("X") == 20000
|
||||
assert not x.controller.command_history
|
||||
|
||||
|
||||
def test_ramp_rate_is_set_before_the_move():
|
||||
motor = _sim_motor(acceleration=50.0)
|
||||
motor.move(1.0, wait=True)
|
||||
history = list(motor.controller.command_history)
|
||||
ramp = history.index("[PUT]: RAMP RATE X 50.000000")
|
||||
move = next(i for i, c in enumerate(history) if c.startswith("[PUT]: MOVEABS X"))
|
||||
assert ramp < move
|
||||
assert _sim_state().axis("X").ramp_rate == 50.0
|
||||
|
||||
|
||||
def test_line_trigger_window_is_relative_to_current_position():
|
||||
x, _, _ = _capture_setup()
|
||||
x.move(-1.0, wait=True)
|
||||
x.arm_line_trigger(0.0, 1.0)
|
||||
assert _sim_state().pso["range"] == (20000.0, 40000.0)
|
||||
assert _sim_state().pso["armed"]
|
||||
x.move(2.0, wait=True)
|
||||
assert _sim_state().pso_window_entries == 1
|
||||
x.disarm_line_trigger()
|
||||
assert not _sim_state().pso["armed"]
|
||||
|
||||
|
||||
def test_line_trigger_window_range_keeps_the_reference():
|
||||
x, _, _ = _capture_setup()
|
||||
x.move(-1.0, wait=True)
|
||||
x.arm_line_trigger(0.0, 1.0)
|
||||
x.move(2.0, wait=True)
|
||||
x.set_line_trigger_window(-3.0, 1.5)
|
||||
# relative to the position at arm (-1.0), not to the current one (2.0)
|
||||
assert _sim_state().pso["range"] == (-40000.0, 50000.0)
|
||||
assert "[PUT]: PSOWINDOW X 1 LOAD 0" in x.controller.command_history
|
||||
assert sum("LOAD" in c for c in x.controller.command_history) == 1
|
||||
|
||||
|
||||
def test_line_trigger_window_range_needs_armed_window():
|
||||
x, _, _ = _capture_setup()
|
||||
with pytest.raises(A3200Error, match="not armed"):
|
||||
x.set_line_trigger_window(0.0, 1.0)
|
||||
x.arm_line_trigger(0.5, 1.0)
|
||||
x.disarm_line_trigger()
|
||||
with pytest.raises(A3200Error, match="not armed"):
|
||||
x.set_line_trigger_window(0.0, 1.0)
|
||||
|
||||
|
||||
def test_line_trigger_uses_emulated_quadrature_divider():
|
||||
u = _sim_motor(
|
||||
"U", axis_nr=3, egu="deg", pso_input=7, pso_counts_divider=40, sim_counts_per_unit=560000
|
||||
)
|
||||
u.move(-1.0, wait=True)
|
||||
u.arm_line_trigger(0.0, 1.0)
|
||||
assert "[PUT]: PSOWINDOW U 1 INPUT 7" in u.controller.command_history
|
||||
assert _sim_state().pso["range"] == (14000.0, 28000.0)
|
||||
u.move(2.0, wait=True)
|
||||
assert _sim_state().pso_window_entries == 1
|
||||
|
||||
|
||||
def test_line_trigger_needs_pso_input():
|
||||
motor = _sim_motor()
|
||||
with pytest.raises(A3200Error, match="pso_input"):
|
||||
motor.arm_line_trigger(0.0, 1.0)
|
||||
|
||||
|
||||
def test_capture_calibration_removes_counter_offset():
|
||||
_, _, capture = _capture_setup()
|
||||
offsets = capture.calibrate()
|
||||
assert offsets["enc6"] == pytest.approx(-777 / 20000)
|
||||
assert offsets["enc2"] == pytest.approx(55 / 10000)
|
||||
|
||||
|
||||
def test_capture_rpc_returns_survive_msgpack():
|
||||
# an RPC return value that msgpack cannot decode leaves the scan waiting forever
|
||||
from bec_lib.serialization import MsgpackSerialization
|
||||
|
||||
x, _, capture = _capture_setup()
|
||||
returns = [capture.calibrate()]
|
||||
x.move(-1.0, wait=True)
|
||||
capture.arm(5)
|
||||
x.move(1.0, wait=True)
|
||||
returns.append(capture.read_line())
|
||||
for value in returns:
|
||||
assert MsgpackSerialization.loads(MsgpackSerialization.dumps(value)) == value
|
||||
|
||||
|
||||
def test_capture_line_publishes_positions():
|
||||
x, _, capture = _capture_setup()
|
||||
capture.calibrate()
|
||||
x.move(-1.0, wait=True)
|
||||
capture.arm(5)
|
||||
x.arm_line_trigger(0.0, 1.0)
|
||||
x.move(2.0, wait=True)
|
||||
|
||||
result = capture.read_line()
|
||||
|
||||
assert result["enc6"] == pytest.approx([0.0, 0.2, 0.4, 0.6, 0.8])
|
||||
assert result["enc2"] == pytest.approx([0.3] * 5)
|
||||
msg = capture.positions.get()
|
||||
assert set(msg.signals) == {
|
||||
"aerocapture_positions_enc2",
|
||||
"aerocapture_positions_enc6",
|
||||
"aerocapture_positions_enc2_centre",
|
||||
"aerocapture_positions_enc6_centre",
|
||||
}
|
||||
assert msg.metadata["async_update"] == {"type": "add", "max_shape": [None]}
|
||||
|
||||
|
||||
def test_capture_reads_in_chunks():
|
||||
x, _, capture = _capture_setup()
|
||||
capture.controller.array_read_chunk = 3
|
||||
x.move(-1.0, wait=True)
|
||||
capture.arm(4)
|
||||
x.arm_line_trigger(0.0, 1.0)
|
||||
x.move(2.0, wait=True)
|
||||
result = capture.read_line()
|
||||
assert len(result["enc6"]) == 4
|
||||
reads = [c for c in capture.controller.command_history if "ARRAY X READ" in c]
|
||||
assert reads == [
|
||||
"[PUT]: ARRAY X READ $global[0] 0 3 FAST",
|
||||
"[PUT]: ARRAY X READ $global[0] 3 3 FAST",
|
||||
"[PUT]: ARRAY X READ $global[0] 6 2 FAST",
|
||||
]
|
||||
|
||||
|
||||
def test_capture_needs_two_channels():
|
||||
with pytest.raises(ValueError, match="two encoder channels"):
|
||||
SimA3200PositionCapture(
|
||||
name="aerocapture", host=_HOST, port=_PORT, channels={6: "X"}, device_manager=DMMock()
|
||||
)
|
||||
|
||||
|
||||
def test_capture_rejects_analog_sources():
|
||||
with pytest.raises(ValueError, match="out of range"):
|
||||
SimA3200PositionCapture(
|
||||
name="aerocapture",
|
||||
host=_HOST,
|
||||
port=_PORT,
|
||||
channels={6: "X", 12: "Y"},
|
||||
device_manager=DMMock(),
|
||||
)
|
||||
|
||||
|
||||
def test_move_waits_for_position_error_within_tolerance():
|
||||
motor = _sim_motor(velocity=100.0, tolerance=0.001)
|
||||
axis = SimStateRegistry.get(SimA3200State, _HOST, _PORT).axis("X")
|
||||
axis.settle_time = 0.3
|
||||
axis.settle_error = 0.01
|
||||
start = time.time()
|
||||
motor.move(1.0, wait=True)
|
||||
assert time.time() - start >= 0.3
|
||||
assert motor.controller.position_error("X") == 0.0
|
||||
|
||||
|
||||
def test_move_does_not_settle_on_a_zero_crossing():
|
||||
# ringing after the stop: one reading within tolerance, then out again, then settled
|
||||
motor = _sim_motor(velocity=100.0, tolerance=0.001, in_position_hold=0.1)
|
||||
errors = iter([0.0] + [0.01] * 5)
|
||||
motor.controller.position_error = lambda axis: next(errors, 0.0)
|
||||
start = time.time()
|
||||
status = motor.move(1.0, wait=True)
|
||||
# 5 readings out of tolerance (~0.1 s), then held for 0.1 s
|
||||
assert time.time() - start >= 0.2
|
||||
assert status.success
|
||||
|
||||
|
||||
def test_move_ignores_in_position_tighter_than_tolerance():
|
||||
# settled within tolerance, but outside the controller's InPositionDistance
|
||||
motor = _sim_motor(velocity=100.0, tolerance=0.001)
|
||||
axis = SimStateRegistry.get(SimA3200State, _HOST, _PORT).axis("X")
|
||||
axis.settle_time = 5.0
|
||||
axis.settle_error = 2e-5
|
||||
start = time.time()
|
||||
status = motor.move(1.0, wait=True)
|
||||
assert time.time() - start < 0.5
|
||||
assert status.success
|
||||
assert not motor.status()["in_position"]
|
||||
|
||||
|
||||
def test_move_not_settled_still_finishes():
|
||||
motor = _sim_motor(velocity=100.0, in_position_timeout=0.1)
|
||||
axis = SimStateRegistry.get(SimA3200State, _HOST, _PORT).axis("X")
|
||||
axis.settle_time = 5.0
|
||||
axis.settle_error = 0.01
|
||||
start = time.time()
|
||||
motor.move(1.0, wait=True)
|
||||
assert time.time() - start < 1.0
|
||||
assert motor.readback.get() == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_move_without_in_position_wait():
|
||||
motor = _sim_motor(velocity=100.0, tolerance=1e-9)
|
||||
SimStateRegistry.get(SimA3200State, _HOST, _PORT).axis("X").settle_time = 5.0
|
||||
motor.wait_in_position.put(False)
|
||||
start = time.time()
|
||||
status = motor.move(1.0, wait=True)
|
||||
assert time.time() - start < 0.5
|
||||
assert status.success
|
||||
|
||||
|
||||
def test_max_scan_velocity_defaults_to_velocity():
|
||||
assert _sim_motor(velocity=5.0).max_scan_velocity.get() == 5.0
|
||||
assert _sim_motor(velocity=5.0, max_scan_velocity=50.0).max_scan_velocity.get() == 50.0
|
||||
|
||||
|
||||
def test_capture_line_centre_shift():
|
||||
x, _, capture = _capture_setup()
|
||||
capture.calibrate()
|
||||
x.move(-1.0, wait=True)
|
||||
capture.arm(5)
|
||||
x.arm_line_trigger(0.0, 1.0)
|
||||
x.move(2.0, wait=True)
|
||||
|
||||
result = capture.read_line(centre_shift=0.1)
|
||||
# X moves forward: shifted forward; Y does not move: unchanged
|
||||
assert result["enc6_centre"] == pytest.approx([0.1, 0.3, 0.5, 0.7, 0.9])
|
||||
assert result["enc2_centre"] == pytest.approx(result["enc2"])
|
||||
|
||||
|
||||
def test_capture_line_centre_shift_backward():
|
||||
_, _, capture = _capture_setup()
|
||||
capture.arm(3)
|
||||
# backward line: X (channel 6) counts down, Y (channel 2) constant; interleaved 2, 6
|
||||
capture.controller.read_drive_array = lambda axis, start, count: [
|
||||
3000, 60000, 3000, 40000, 3000, 20000
|
||||
]
|
||||
result = capture.read_line(centre_shift=0.1)
|
||||
assert result["enc6_centre"] == pytest.approx([x - 0.1 for x in result["enc6"]])
|
||||
assert result["enc2_centre"] == pytest.approx(result["enc2"])
|
||||
|
||||
|
||||
def test_capture_applies_the_motor_sign():
|
||||
"""Captured positions are in the coordinates of the BEC motors (sign from their config)."""
|
||||
x = _sim_motor("X", axis_nr=0, pso_input=6, sim_counts_per_unit=20000, sign=-1)
|
||||
y = _sim_motor("Y", axis_nr=2, pso_input=2, sim_counts_per_unit=10000)
|
||||
dm = DMMock()
|
||||
dm.devices["aerox"] = x
|
||||
dm.devices["aeroy"] = y
|
||||
capture = SimA3200PositionCapture(
|
||||
name="aerocapture", host=_HOST, port=_PORT, channels={6: "X", 2: "Y"}, device_manager=dm
|
||||
)
|
||||
capture.wait_for_connection()
|
||||
capture.calibrate()
|
||||
assert capture._signs == {6: -1, 2: 1}
|
||||
capture._offsets = {6: 0.0, 2: 0.0}
|
||||
capture.arm(3)
|
||||
# forward line in user coordinates = X counts going down; interleaved channels 2, 6
|
||||
capture.controller.read_drive_array = lambda axis, start, count: [
|
||||
3000, -20000, 3000, -24000, 3000, -28000
|
||||
]
|
||||
result = capture.read_line(centre_shift=0.1)
|
||||
assert result["enc6"] == pytest.approx([1.0, 1.2, 1.4])
|
||||
assert result["enc6_centre"] == pytest.approx([1.1, 1.3, 1.5])
|
||||
assert result["enc2"] == pytest.approx([0.3] * 3)
|
||||
|
||||
|
||||
def test_capture_without_motor_uses_controller_coordinates():
|
||||
_, _, capture = _capture_setup() # DMMock without motors
|
||||
capture.calibrate()
|
||||
assert capture._signs == {6: 1, 2: 1}
|
||||
|
||||
|
||||
def test_capture_progress_counts_lines():
|
||||
from types import SimpleNamespace
|
||||
|
||||
x, _, capture = _capture_setup()
|
||||
capture.scan_info = SimpleNamespace(msg=SimpleNamespace(frames_per_trigger=5, num_points=2))
|
||||
capture.on_stage()
|
||||
assert capture.progress.get().value == 0 and capture.progress.get().max_value == 2
|
||||
capture.calibrate()
|
||||
for target in (-1.0, 1.0):
|
||||
capture.arm(5)
|
||||
x.move(target, wait=True)
|
||||
capture.read_line()
|
||||
progress = capture.progress.get()
|
||||
assert progress.value == 2 and progress.done
|
||||
@@ -0,0 +1,309 @@
|
||||
import pytest
|
||||
|
||||
from csaxs_bec.devices.panda_box.panda_line_trigger import (
|
||||
PandaControl,
|
||||
PandaLineTrigger,
|
||||
PandaLineTriggerError,
|
||||
)
|
||||
|
||||
|
||||
class FakePanda(PandaControl):
|
||||
"""In-memory PandA: fields, arm gate, counters; a line runs completely at its trigger."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("fake-panda")
|
||||
self.fields = {}
|
||||
self.writes = []
|
||||
self.busy = False # simulate a running line: edges are ignored
|
||||
|
||||
def open(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def send(self, command: str) -> str:
|
||||
if command == "*IDN?":
|
||||
return "PandA SW: fake"
|
||||
if command.endswith("?"):
|
||||
return self.fields.get(command[:-1], "0")
|
||||
field, value = command.split("=", 1)
|
||||
self.writes.append(command)
|
||||
f = self.fields
|
||||
if field == "SRGATE1.FORCE_SET":
|
||||
f["SRGATE1.OUT"] = "1"
|
||||
elif field == "SRGATE1.FORCE_RST":
|
||||
f["SRGATE1.OUT"] = "0"
|
||||
elif field.startswith("COUNTER") and field.endswith(".ENABLE") and value == "ONE":
|
||||
f[field.replace("ENABLE", "OUT")] = "0"
|
||||
elif field == "BITS.A" and value == "1":
|
||||
f["COUNTER6.OUT"] = str(int(f.get("COUNTER6.OUT", "0")) + 1)
|
||||
if f.get("SRGATE1.OUT") == "1" and not self.busy:
|
||||
f["COUNTER7.OUT"] = str(int(f.get("COUNTER7.OUT", "0")) + 1)
|
||||
pulses = int(f.get("COUNTER8.OUT", "0")) + int(f.get("PULSE4.PULSES", "1"))
|
||||
f["COUNTER8.OUT"] = str(pulses)
|
||||
if f.get("SRGATE1.RST") == "LUT7.OUT":
|
||||
f["SRGATE1.OUT"] = "0"
|
||||
else:
|
||||
f[field] = value
|
||||
return ""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trigger():
|
||||
device = PandaLineTrigger(name="pandatrig", host="fake-panda")
|
||||
device.PARAM_QUIET_TIME = 0.01
|
||||
device.panda = FakePanda()
|
||||
device.wait_for_connection()
|
||||
return device
|
||||
|
||||
|
||||
def test_setup_layout_wires_the_blocks(trigger):
|
||||
fields = trigger.panda.fields
|
||||
assert fields["PULSE3.TRIG"] == fields["PULSE4.TRIG"] == "LUT7.OUT"
|
||||
assert fields["LUT7.FUNC"] == "A&B&~C&~D"
|
||||
assert fields["LUT7.TYPEA"] == "Pulse-On-Rising-Edge"
|
||||
assert (fields["LUT7.INPC"], fields["LUT7.INPD"]) == ("PULSE3.OUT", "PULSE4.OUT")
|
||||
assert fields["LUT8.INPA"] == "TTLIN3.VAL"
|
||||
assert fields["COUNTER6.TRIG"] == "LUT8.OUT"
|
||||
assert fields["COUNTER7.TRIG"] == "LUT7.OUT"
|
||||
assert fields["COUNTER8.TRIG"] == "PULSE4.OUT"
|
||||
assert fields["TTLOUT2.VAL"] == "PULSE3.OUT"
|
||||
assert fields["TTLOUT3.VAL"] == "PULSE4.OUT"
|
||||
assert fields["TTLIN3.TERM"] == "50-Ohm"
|
||||
assert fields["PULSE3.ENABLE"] == fields["PULSE4.ENABLE"] == "ONE"
|
||||
|
||||
|
||||
def test_configure_line_sets_timing(trigger):
|
||||
trigger.configure_line(10, exp_time=0.01, readout_time=0.002, shutter_delay=0.005)
|
||||
fields = trigger.panda.fields
|
||||
assert float(fields["PULSE4.DELAY"]) == pytest.approx(0.005)
|
||||
assert float(fields["PULSE4.WIDTH"]) == pytest.approx(0.008)
|
||||
assert float(fields["PULSE4.STEP"]) == pytest.approx(0.01)
|
||||
assert fields["PULSE4.PULSES"] == "10"
|
||||
# shutter: from the trigger to the end of the last exposure
|
||||
assert float(fields["PULSE3.WIDTH"]) == pytest.approx(0.005 + 9 * 0.01 + 0.008)
|
||||
|
||||
|
||||
def test_pulse_parameters_are_written_only_on_change(trigger):
|
||||
trigger.configure_line(10, exp_time=0.01)
|
||||
trigger.panda.writes.clear()
|
||||
trigger.configure_line(10, exp_time=0.01)
|
||||
assert not trigger.panda.writes
|
||||
trigger.configure_line(11, exp_time=0.01)
|
||||
assert "PULSE4.PULSES=11" in trigger.panda.writes
|
||||
|
||||
|
||||
def test_never_toggles_pulse_enable_after_setup(trigger):
|
||||
trigger.configure_line(5, exp_time=0.01)
|
||||
trigger.panda.writes.clear()
|
||||
trigger.arm()
|
||||
trigger.soft_line_trigger()
|
||||
trigger.wait_lines_done(1, timeout=1)
|
||||
trigger.disarm()
|
||||
trigger.stop()
|
||||
assert not [w for w in trigger.panda.writes if w.startswith("PULSE")]
|
||||
|
||||
|
||||
def test_stays_armed_for_many_lines(trigger):
|
||||
trigger.configure_line(5, exp_time=0.01)
|
||||
trigger.arm()
|
||||
for _ in range(4):
|
||||
trigger.soft_line_trigger()
|
||||
status = trigger.wait_lines_done(4, timeout=1)
|
||||
assert (status["lines"], status["pulses"], status["armed"]) == (4, 20, True)
|
||||
|
||||
|
||||
def test_one_shot_accepts_one_edge(trigger):
|
||||
trigger.configure_line(5, exp_time=0.01)
|
||||
trigger.arm(one_shot=True)
|
||||
trigger.soft_line_trigger()
|
||||
assert trigger.wait_lines_done(1, timeout=1)["armed"] is False
|
||||
trigger.soft_line_trigger()
|
||||
status = trigger.wait_lines_done(1, timeout=1)
|
||||
assert status["triggers"] == 2 and status["lines"] == 1
|
||||
|
||||
|
||||
def test_edge_during_line_is_ignored(trigger):
|
||||
trigger.configure_line(5, exp_time=0.01)
|
||||
trigger.arm()
|
||||
trigger.soft_line_trigger()
|
||||
trigger.panda.busy = True
|
||||
trigger.soft_line_trigger()
|
||||
trigger.panda.busy = False
|
||||
status = trigger.wait_lines_done(1, timeout=1)
|
||||
assert status["triggers"] == 2 and status["lines"] == 1 and status["pulses"] == 5
|
||||
|
||||
|
||||
def test_arm_needs_configure(trigger):
|
||||
with pytest.raises(PandaLineTriggerError, match="configure_line"):
|
||||
trigger.arm()
|
||||
|
||||
|
||||
def test_wait_lines_done_times_out_without_trigger(trigger):
|
||||
trigger.configure_line(5, exp_time=0.01)
|
||||
trigger.arm()
|
||||
with pytest.raises(PandaLineTriggerError, match="not finished"):
|
||||
trigger.wait_lines_done(1, timeout=0.1, poll=0.01)
|
||||
|
||||
|
||||
def test_wait_lines_done_reports_too_many_lines(trigger):
|
||||
trigger.configure_line(5, exp_time=0.01)
|
||||
trigger.arm()
|
||||
for _ in range(3):
|
||||
trigger.soft_line_trigger()
|
||||
with pytest.raises(PandaLineTriggerError, match="expected 2 line"):
|
||||
trigger.wait_lines_done(2, timeout=0.1, poll=0.01)
|
||||
|
||||
|
||||
def test_wait_lines_done_reports_dropped(trigger):
|
||||
trigger.configure_line(5, exp_time=0.01)
|
||||
trigger.arm()
|
||||
trigger.panda.fields["PULSE4.DROPPED"] = "1"
|
||||
with pytest.raises(PandaLineTriggerError, match="dropped"):
|
||||
trigger.wait_lines_done(1, timeout=0.1, poll=0.01)
|
||||
|
||||
|
||||
def test_dropped_counts_only_since_arm(trigger):
|
||||
trigger.configure_line(5, exp_time=0.01)
|
||||
trigger.panda.fields["PULSE4.DROPPED"] = "3"
|
||||
trigger.arm()
|
||||
assert trigger.line_status()["dropped"] == 0
|
||||
|
||||
|
||||
def test_arm_refuses_high_trigger_input(trigger):
|
||||
trigger.configure_line(5, exp_time=0.01)
|
||||
trigger.panda.fields["LUT8.OUT"] = "1"
|
||||
with pytest.raises(PandaLineTriggerError, match="is high"):
|
||||
trigger.arm()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs, match",
|
||||
[
|
||||
({"n_pulses": 0, "exp_time": 0.01}, "at least 1"),
|
||||
({"n_pulses": 5, "exp_time": 0.01, "readout_time": 0.01}, "shorter than"),
|
||||
({"n_pulses": 5, "exp_time": 0.01, "shutter_delay": -0.001}, "shutter_delay"),
|
||||
],
|
||||
)
|
||||
def test_configure_line_checks_arguments(trigger, kwargs, match):
|
||||
with pytest.raises(PandaLineTriggerError, match=match):
|
||||
trigger.configure_line(**kwargs)
|
||||
|
||||
|
||||
def test_disarm_closes_the_gate(trigger):
|
||||
trigger.configure_line(5, exp_time=0.01)
|
||||
trigger.arm()
|
||||
trigger.disarm()
|
||||
trigger.soft_line_trigger()
|
||||
status = trigger.line_status()
|
||||
assert (status["armed"], status["lines"], status["pulses"]) == (False, 0, 0)
|
||||
|
||||
|
||||
def test_control_reply_parsing():
|
||||
control = PandaControl("fake")
|
||||
control._sock = type("S", (), {"sendall": lambda self, data: None})()
|
||||
control._buffer = b"OK =1.5\nOK\nERR No such field\n"
|
||||
assert control.send("PULSE4.WIDTH?") == "1.5"
|
||||
assert control.send("PULSE4.WIDTH=1") == ""
|
||||
with pytest.raises(PandaLineTriggerError, match="No such field"):
|
||||
control.send("PULSE4.FOO?")
|
||||
|
||||
|
||||
def test_falling_edge_inverts_the_input():
|
||||
device = PandaLineTrigger(name="pandatrig", host="fake-panda", trigger_edge="falling")
|
||||
device.panda = FakePanda()
|
||||
device.wait_for_connection()
|
||||
assert device.panda.fields["LUT8.FUNC"] == "~A|B"
|
||||
|
||||
|
||||
def test_invalid_trigger_edge():
|
||||
with pytest.raises(ValueError, match="trigger_edge"):
|
||||
PandaLineTrigger(name="pandatrig", host="fake-panda", trigger_edge="both")
|
||||
|
||||
|
||||
def test_configure_line_waits_for_a_late_replayed_train(trigger):
|
||||
"""The replayed train starts only after the write; configure_line must outlast it."""
|
||||
import threading
|
||||
import time
|
||||
|
||||
trigger.PARAM_QUIET_TIME = 0.2
|
||||
fields = trigger.panda.fields
|
||||
|
||||
def replay():
|
||||
time.sleep(0.05)
|
||||
fields["PULSE4.OUT"] = "1"
|
||||
time.sleep(0.05)
|
||||
fields["PULSE4.OUT"] = "0"
|
||||
|
||||
threading.Thread(target=replay, daemon=True).start()
|
||||
t0 = time.time()
|
||||
trigger.configure_line(5, 0.01)
|
||||
# the train ended at ~0.1 s, then 0.2 s quiet
|
||||
assert time.time() - t0 >= 0.28
|
||||
|
||||
|
||||
def test_unchanged_fields_are_not_written(trigger):
|
||||
"""A new device instance on a configured PandA writes nothing (no replayed trains)."""
|
||||
trigger.configure_line(5, 0.01, 0.002)
|
||||
fields = dict(trigger.panda.fields)
|
||||
again = PandaLineTrigger(name="pandatrig", host="fake-panda")
|
||||
again.PARAM_QUIET_TIME = 0.01
|
||||
again.panda = FakePanda()
|
||||
again.panda.fields = fields
|
||||
again.wait_for_connection()
|
||||
again.configure_line(5, 0.01, 0.002)
|
||||
values = [w for w in again.panda.writes if not w.split("=", 1)[1] == ""]
|
||||
assert values == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"device_cls",
|
||||
[
|
||||
"csaxs_bec.devices.panda_box.panda_line_trigger.PandaLineTrigger",
|
||||
"csaxs_bec.devices.aerotech.a3200.A3200Motor",
|
||||
"csaxs_bec.devices.aerotech.a3200.A3200PositionCapture",
|
||||
],
|
||||
)
|
||||
def test_user_access_names_not_protected_by_bec(device_cls):
|
||||
# the device server refuses to load a device whose USER_ACCESS shadows a DeviceBase name
|
||||
import importlib
|
||||
|
||||
from bec_server.device_server.devices.device_serializer import get_protected_class_methods
|
||||
|
||||
module, name = device_cls.rsplit(".", 1)
|
||||
cls = getattr(importlib.import_module(module), name)
|
||||
assert not set(cls.USER_ACCESS) & set(get_protected_class_methods())
|
||||
|
||||
|
||||
def test_ignored_edges_logged_once_per_line_as_info(trigger):
|
||||
from unittest import mock
|
||||
|
||||
from csaxs_bec.devices.panda_box import panda_line_trigger
|
||||
|
||||
trigger.configure_line(5, exp_time=0.01)
|
||||
trigger.arm()
|
||||
with mock.patch.object(panda_line_trigger, "logger") as logger:
|
||||
for n_lines in (1, 2):
|
||||
trigger.soft_line_trigger()
|
||||
trigger.panda.busy = True
|
||||
trigger.soft_line_trigger() # entry noise during the line
|
||||
trigger.panda.busy = False
|
||||
trigger.wait_lines_done(n_lines, timeout=1)
|
||||
trigger.soft_line_trigger()
|
||||
trigger.wait_lines_done(3, timeout=1) # clean line: nothing logged
|
||||
logger.warning.assert_not_called()
|
||||
messages = [call.args[0] for call in logger.info.call_args_list]
|
||||
assert len(messages) == 2
|
||||
assert "1 trigger edge(s) ignored in line 1" in messages[0]
|
||||
assert "1 trigger edge(s) ignored in line 2" in messages[1] and "2 since arm" in messages[1]
|
||||
|
||||
|
||||
def test_shutter_to_open_delay_as_ddg1(trigger):
|
||||
assert trigger.get_shutter_to_open_delay() == pytest.approx(8e-3) # DDG1 default
|
||||
trigger.set_shutter_to_open_delay(0.012)
|
||||
assert trigger.get_shutter_to_open_delay() == pytest.approx(0.012)
|
||||
with pytest.raises(ValueError, match="Shutter-to-open delay"):
|
||||
trigger.set_shutter_to_open_delay(0.2)
|
||||
configured = PandaLineTrigger(name="p2", host="fake-panda", shutter_delay=0.0)
|
||||
assert configured.get_shutter_to_open_delay() == 0.0
|
||||
@@ -16,6 +16,8 @@ def _reset_controller_singletons():
|
||||
"""Each test gets fresh controllers (the singleton registry is class-level)."""
|
||||
SmargopoloController._controller_instances.clear()
|
||||
yield
|
||||
for controller in SmargopoloController._controller_instances.values():
|
||||
controller.stop_monitor()
|
||||
SmargopoloController._controller_instances.clear()
|
||||
|
||||
|
||||
@@ -196,3 +198,71 @@ def test_move_scs_aborts_on_controller_fault():
|
||||
coord.controller.transport._mode = Mode.ERROR
|
||||
with pytest.raises(SmargopoloModeError):
|
||||
coord.move_scs(SHX=1e6, wait=True)
|
||||
|
||||
|
||||
# -- readback monitor (moves made outside BEC) --------------------------------
|
||||
|
||||
|
||||
def _wait_for(condition, timeout=2.0):
|
||||
import time
|
||||
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if condition():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
def test_monitor_publishes_moves_made_outside_bec():
|
||||
from csaxs_bec.devices.smargon.transport.base import Axis
|
||||
|
||||
sgx = _motor("sgx", "SHX", monitor_interval=0.02)
|
||||
published = []
|
||||
sgx.subscribe(lambda value, **_: published.append(value), event_type="readback", run=False)
|
||||
# the smargopolo web interface moves the axis
|
||||
sgx.controller.transport._positions[Axis.SHX] = 1.234
|
||||
assert _wait_for(lambda: sgx.readback.get() == pytest.approx(1.234))
|
||||
assert published and published[-1] == pytest.approx(1.234)
|
||||
sgx.destroy()
|
||||
|
||||
|
||||
def test_monitor_does_not_republish_unchanged_positions():
|
||||
sgx = _motor("sgx", "SHX", monitor_interval=0.02)
|
||||
published = []
|
||||
sgx.subscribe(lambda value, **_: published.append(value), event_type="readback", run=False)
|
||||
import time
|
||||
|
||||
time.sleep(0.15)
|
||||
assert published == []
|
||||
sgx.destroy()
|
||||
|
||||
|
||||
def test_monitor_skips_while_bec_moves():
|
||||
from csaxs_bec.devices.smargon.transport.base import Axis
|
||||
|
||||
sgx = _motor("sgx", "SHX", monitor_interval=0.02)
|
||||
sgx.motor_is_moving.put(1)
|
||||
sgx.controller.transport._positions[Axis.SHX] = 0.5
|
||||
import time
|
||||
|
||||
time.sleep(0.15)
|
||||
assert sgx.readback.get() != pytest.approx(0.5)
|
||||
sgx.motor_is_moving.put(0)
|
||||
assert _wait_for(lambda: sgx.readback.get() == pytest.approx(0.5))
|
||||
sgx.destroy()
|
||||
|
||||
|
||||
def test_monitor_stops_with_the_last_axis_and_can_be_disabled():
|
||||
sgx = _motor("sgx", "SHX", monitor_interval=0.02)
|
||||
sgy = _motor("sgy", "SHY", monitor_interval=0.02)
|
||||
controller = sgx.controller
|
||||
sgx.destroy()
|
||||
assert controller._monitor_thread is not None and controller._monitor_thread.is_alive()
|
||||
sgy.destroy()
|
||||
assert controller._monitor_thread is None
|
||||
|
||||
SmargopoloController._controller_instances.clear()
|
||||
off = _motor("sgz", "SHZ", monitor_interval=0)
|
||||
assert off.controller._monitor_thread is None
|
||||
off.destroy()
|
||||
@@ -0,0 +1,683 @@
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from bec_server.scan_server.errors import LimitError
|
||||
from bec_server.scan_server.tests.scan_fixtures import MockCustomDevice
|
||||
from bec_server.scan_server.tests.scan_hook_tests import (
|
||||
DEFAULT_HOOK_TESTS,
|
||||
PREMOVE_HOOK_TESTS,
|
||||
run_scan_tests,
|
||||
)
|
||||
|
||||
AEROTECH_CONT_GRID_HOOK_TESTS = [*DEFAULT_HOOK_TESTS, *PREMOVE_HOOK_TESTS]
|
||||
|
||||
|
||||
def _signal(obj_name, kind="config"):
|
||||
return {"obj_name": obj_name, "kind_str": kind, "describe": {"precision": 3}}
|
||||
|
||||
|
||||
def _add_devices(
|
||||
device_manager, capture=True, acceleration=100.0, panda=True, max_scan_velocity=10.0
|
||||
):
|
||||
aerox = MockCustomDevice(
|
||||
"aerox",
|
||||
device_info={
|
||||
"signals": {
|
||||
"readback": _signal("aerox", "hinted"),
|
||||
"motor_velocity": _signal("aerox_motor_velocity"),
|
||||
"motor_acceleration": _signal("aerox_motor_acceleration"),
|
||||
"wait_in_position": _signal("aerox_wait_in_position"),
|
||||
}
|
||||
},
|
||||
signal_read_values={
|
||||
"aerox": 0.0,
|
||||
"aerox_motor_velocity": 10.0,
|
||||
"aerox_motor_acceleration": acceleration,
|
||||
"aerox_wait_in_position": True,
|
||||
},
|
||||
)
|
||||
aerox.arm_line_trigger = mock.MagicMock()
|
||||
aerox.disarm_line_trigger = mock.MagicMock()
|
||||
aerox.set_line_trigger_window = mock.MagicMock()
|
||||
# signal writes go through RPC; mock them, keep the configured values readable
|
||||
for signal_name, value in (
|
||||
("motor_velocity", 10.0),
|
||||
("max_scan_velocity", max_scan_velocity),
|
||||
("wait_in_position", True),
|
||||
):
|
||||
setattr(aerox, signal_name, mock.MagicMock(**{"get.return_value": value}))
|
||||
aeroy = MockCustomDevice(
|
||||
"aeroy",
|
||||
device_info={"signals": {"readback": _signal("aeroy", "hinted")}},
|
||||
signal_read_values={"aeroy": 0.0},
|
||||
)
|
||||
ddg1 = MockCustomDevice(
|
||||
"ddg1",
|
||||
device_info={"signals": {"readback": _signal("ddg1", "hinted")}},
|
||||
signal_read_values={"ddg1": 0.0},
|
||||
)
|
||||
ddg1.get_shutter_to_open_delay = mock.MagicMock(return_value=2e-3)
|
||||
ddg1.set_shutter_to_open_delay = mock.MagicMock()
|
||||
ddg1.set_trigger = mock.MagicMock()
|
||||
ddg2 = MockCustomDevice(
|
||||
"ddg2",
|
||||
device_info={"signals": {"readback": _signal("ddg2", "hinted")}},
|
||||
signal_read_values={"ddg2": 0.0},
|
||||
)
|
||||
ddg2.effective_readout_times = mock.MagicMock(return_value={"ab": 2e-4, "cd": 2e-4})
|
||||
mcs = MockCustomDevice(
|
||||
"mcs",
|
||||
device_info={"signals": {"readback": _signal("mcs", "hinted")}},
|
||||
signal_read_values={"mcs": 0.0},
|
||||
)
|
||||
devices = [aerox, aeroy, ddg1, ddg2, mcs]
|
||||
if panda:
|
||||
pandatrig = MockCustomDevice(
|
||||
"pandatrig",
|
||||
device_info={"signals": {"readback": _signal("pandatrig", "hinted")}},
|
||||
signal_read_values={"pandatrig": 0.0},
|
||||
)
|
||||
for method in ("configure_line", "arm", "disarm", "wait_lines_done"):
|
||||
setattr(pandatrig, method, mock.MagicMock())
|
||||
pandatrig.get_readout_time = mock.MagicMock(return_value=0.02)
|
||||
pandatrig.get_shutter_to_open_delay = mock.MagicMock(return_value=8e-3)
|
||||
devices.append(pandatrig)
|
||||
if capture:
|
||||
aerocapture = MockCustomDevice(
|
||||
"aerocapture",
|
||||
device_info={"signals": {"readback": _signal("aerocapture", "hinted")}},
|
||||
signal_read_values={"aerocapture": 0.0},
|
||||
)
|
||||
for method in ("calibrate", "arm", "read_line", "disarm"):
|
||||
setattr(aerocapture, method, mock.MagicMock())
|
||||
devices.append(aerocapture)
|
||||
for device in devices:
|
||||
device_manager.add_device(device, replace=True)
|
||||
|
||||
|
||||
def _assemble(v4_scan_assembler, device_manager, **kwargs):
|
||||
_add_devices(
|
||||
device_manager,
|
||||
**{
|
||||
k: kwargs.pop(k)
|
||||
for k in ("capture", "acceleration", "panda", "max_scan_velocity")
|
||||
if k in kwargs
|
||||
},
|
||||
)
|
||||
return v4_scan_assembler(
|
||||
"aerotech_cont_grid", "aerox", -1.0, 1.0, 0.5, "aeroy", -2.0, 2.0, 2, exp_time=0.1, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def _ready_for_core(scan, nth_done_status_mock):
|
||||
"""Prepared scan with mocked motion and trigger receivers."""
|
||||
scan.prepare_scan()
|
||||
scan.fast_axis = mock.MagicMock()
|
||||
scan.actions.set = mock.MagicMock(
|
||||
side_effect=lambda *args, **kwargs: nth_done_status_mock(resolve_after=1)
|
||||
)
|
||||
scan.actions.read_monitored_devices = mock.MagicMock()
|
||||
return scan
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("hook_name", "hook_tests"), AEROTECH_CONT_GRID_HOOK_TESTS)
|
||||
def test_aerotech_cont_grid_default_hooks(
|
||||
v4_scan_assembler, device_manager, nth_done_status_mock, hook_name, hook_tests
|
||||
):
|
||||
scan = _assemble(v4_scan_assembler, device_manager)
|
||||
run_scan_tests(scan, [(hook_name, hook_tests)], nth_done_status_mock=nth_done_status_mock)
|
||||
|
||||
|
||||
def test_line_geometry(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager, settle_time=0.1)
|
||||
scan.prepare_scan()
|
||||
line = scan._line
|
||||
|
||||
velocity = 0.5 / 0.1
|
||||
ramp = velocity**2 / (2 * 100.0)
|
||||
margin = 1.0 * ramp + velocity * 0.1
|
||||
lead = velocity * 2e-3 # DDG1 shutter delay
|
||||
gap = velocity * 2e-4 / 2 # half the DDG2 readout time: gates centred in their cells
|
||||
assert line["readout_time"] == pytest.approx(2e-4)
|
||||
assert scan.frames_per_line == 5
|
||||
assert line["num_lines"] == 3
|
||||
assert line["target_velocity"] == pytest.approx(velocity)
|
||||
assert line["exposure_end"] == pytest.approx(-1.0 + 5 * 0.5)
|
||||
# PSO window: entered at the low edge going forward, at the high edge going backward
|
||||
assert line["trigger_low"] == pytest.approx(-1.0 - lead + gap)
|
||||
assert line["trigger_high"] == pytest.approx(line["exposure_end"] + lead - gap)
|
||||
assert line["line_start"] == pytest.approx(line["trigger_low"] - ramp - margin)
|
||||
assert line["line_end"] == pytest.approx(line["trigger_high"] + ramp + margin)
|
||||
assert scan.scan_info.frames_per_trigger == 5
|
||||
|
||||
|
||||
def test_snake_positions(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager)
|
||||
scan.prepare_scan()
|
||||
# last point of each line: forward, backward, forward
|
||||
assert np.allclose(scan.positions, [[1.0, -2.0], [-1.0, 0.0], [1.0, 2.0]])
|
||||
computed = np.asarray(scan.scan_info.additional_scan_parameters["computed_positions"])
|
||||
assert np.allclose(computed[:5, 0], [-1.0, -0.5, 0.0, 0.5, 1.0])
|
||||
assert np.allclose(computed[5:10, 0], [1.0, 0.5, 0.0, -0.5, -1.0])
|
||||
|
||||
|
||||
def test_shutter_delay_without_ddg(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager, trigger_source=None)
|
||||
scan.prepare_scan()
|
||||
assert scan._line["shutter_open_delay"] == 0.0
|
||||
assert scan._line["readout_time"] == 0.0
|
||||
assert scan._line["trigger_low"] == pytest.approx(-1.0)
|
||||
assert scan.ddg1 is None and scan.mcs is None
|
||||
|
||||
|
||||
def test_shutter_delay_argument(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager, trigger_source="panda", shutter_delay=4e-3)
|
||||
scan.prepare_scan()
|
||||
# PandA readout time 20 ms (device setting): gap 5 mm/s * 10 ms
|
||||
assert scan._line["trigger_low"] == pytest.approx(-1.0 - 5.0 * 4e-3 + 5.0 * 0.01)
|
||||
|
||||
|
||||
def test_invalid_trigger_source_aborts(v4_scan_assembler, device_manager):
|
||||
with pytest.raises(ValueError, match="trigger_source"):
|
||||
_assemble(v4_scan_assembler, device_manager, trigger_source="rio")
|
||||
|
||||
|
||||
def test_missing_panda_aborts(v4_scan_assembler, device_manager):
|
||||
device_manager.devices.pop("pandatrig", None)
|
||||
with pytest.raises(ValueError, match="pandatrig"):
|
||||
_assemble(v4_scan_assembler, device_manager, panda=False, trigger_source="panda")
|
||||
|
||||
|
||||
def test_velocity_above_configured_velocity_aborts(v4_scan_assembler, device_manager):
|
||||
_add_devices(device_manager)
|
||||
scan = v4_scan_assembler(
|
||||
"aerotech_cont_grid", "aerox", -10.0, 10.0, 2.0, "aeroy", -2.0, 2.0, 2, exp_time=0.1
|
||||
)
|
||||
with pytest.raises(ValueError, match="exceeds the maximum scan velocity"):
|
||||
scan.prepare_scan()
|
||||
|
||||
|
||||
def test_scan_faster_than_move_velocity_restores_move_velocity(v4_scan_assembler, device_manager):
|
||||
# 20 mm/s scan, move velocity 10 mm/s, max_scan_velocity 50 mm/s
|
||||
_add_devices(device_manager, max_scan_velocity=50.0)
|
||||
scan = v4_scan_assembler(
|
||||
"aerotech_cont_grid", "aerox", -1.0, 1.0, 0.1, "aeroy", -2.0, 2.0, 2, exp_time=0.005
|
||||
)
|
||||
scan.prepare_scan()
|
||||
assert scan._line["target_velocity"] == pytest.approx(20.0)
|
||||
scan.fast_axis = mock.MagicMock()
|
||||
scan._fast_axis_configured = True
|
||||
|
||||
scan.on_exception(RuntimeError("aborted"))
|
||||
|
||||
scan.fast_axis.motor_velocity.put.assert_called_once_with(10.0)
|
||||
|
||||
|
||||
def test_run_up_outside_limits_aborts(v4_scan_assembler, device_manager):
|
||||
# 20 mm/s at 100 mm/s^2: 2 * 2 mm ramp + 3 mm settle run-up, beyond the +-10 limits
|
||||
_add_devices(device_manager, max_scan_velocity=50.0)
|
||||
scan = v4_scan_assembler(
|
||||
"aerotech_cont_grid", "aerox", -4.0, 4.0, 0.1, "aeroy", -2.0, 2.0, 2, exp_time=0.005
|
||||
)
|
||||
with pytest.raises(LimitError, match="within the limits, but aerox needs .* extra travel"):
|
||||
scan.prepare_scan()
|
||||
|
||||
|
||||
def test_scan_range_outside_limits_aborts(v4_scan_assembler, device_manager):
|
||||
_add_devices(device_manager)
|
||||
scan = v4_scan_assembler(
|
||||
"aerotech_cont_grid", "aerox", -1.0, 11.0, 0.5, "aeroy", -2.0, 2.0, 2, exp_time=0.1
|
||||
)
|
||||
with pytest.raises(LimitError, match="Scan range of aerox"):
|
||||
scan.prepare_scan()
|
||||
|
||||
|
||||
def test_stepper_outside_limits_aborts(v4_scan_assembler, device_manager):
|
||||
_add_devices(device_manager)
|
||||
scan = v4_scan_assembler(
|
||||
"aerotech_cont_grid", "aerox", -1.0, 1.0, 0.5, "aeroy", -2.0, 12.0, 2, exp_time=0.1
|
||||
)
|
||||
with pytest.raises(LimitError, match="aeroy range"):
|
||||
scan.prepare_scan()
|
||||
|
||||
|
||||
def test_missing_acceleration_aborts(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager, acceleration=0.0)
|
||||
with pytest.raises(ValueError, match="No acceleration"):
|
||||
scan.prepare_scan()
|
||||
|
||||
|
||||
def test_missing_capture_device_aborts(v4_scan_assembler, device_manager):
|
||||
device_manager.devices.pop("aerocapture", None)
|
||||
with pytest.raises(ValueError, match="aerocapture"):
|
||||
_assemble(v4_scan_assembler, device_manager, capture=False)
|
||||
|
||||
|
||||
def test_capture_is_always_used(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager)
|
||||
assert scan.capture is device_manager.devices["aerocapture"]
|
||||
|
||||
|
||||
def test_readout_time_longer_than_exposure_aborts(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager)
|
||||
device_manager.devices["ddg2"].effective_readout_times.return_value = {"ab": 0.2}
|
||||
with pytest.raises(ValueError, match="Readout time"):
|
||||
scan.prepare_scan()
|
||||
|
||||
|
||||
def test_pre_scan_arms_once_after_calibration(
|
||||
v4_scan_assembler, device_manager, nth_done_status_mock
|
||||
):
|
||||
scan = _assemble(v4_scan_assembler, device_manager)
|
||||
scan.prepare_scan()
|
||||
order = mock.MagicMock()
|
||||
scan.capture = order.capture
|
||||
scan.fast_axis = order.fast_axis
|
||||
scan.ddg1 = order.ddg1
|
||||
scan._premove_motor_status = nth_done_status_mock(resolve_after=1)
|
||||
scan.actions.pre_scan_all_devices = mock.MagicMock()
|
||||
|
||||
scan.pre_scan()
|
||||
|
||||
line = scan._line
|
||||
calls = [c for c in order.mock_calls if not c[0].endswith(".wait")]
|
||||
assert calls == [
|
||||
mock.call.capture.calibrate(),
|
||||
mock.call.fast_axis.motor_velocity.set(line["target_velocity"]),
|
||||
mock.call.fast_axis.wait_in_position.set(False),
|
||||
mock.call.fast_axis.arm_line_trigger(
|
||||
line["trigger_low"], 2 * line["line_end"] - line["line_start"]
|
||||
),
|
||||
mock.call.ddg1.set_trigger(1), # rising edge: PSO opto output
|
||||
]
|
||||
|
||||
|
||||
def test_ddg_falling_edge_for_open_collector_output(
|
||||
v4_scan_assembler, device_manager, nth_done_status_mock
|
||||
):
|
||||
scan = _assemble(v4_scan_assembler, device_manager, trigger_edge="falling")
|
||||
scan.prepare_scan()
|
||||
scan.fast_axis = mock.MagicMock()
|
||||
scan.ddg1 = mock.MagicMock()
|
||||
scan._premove_motor_status = nth_done_status_mock(resolve_after=1)
|
||||
scan.actions.pre_scan_all_devices = mock.MagicMock()
|
||||
scan.pre_scan()
|
||||
scan.ddg1.set_trigger.assert_called_once_with(2)
|
||||
|
||||
|
||||
def test_invalid_trigger_edge_aborts(v4_scan_assembler, device_manager):
|
||||
with pytest.raises(ValueError, match="trigger_edge"):
|
||||
_assemble(v4_scan_assembler, device_manager, trigger_edge="both")
|
||||
|
||||
|
||||
def test_pre_scan_configures_and_arms_panda(
|
||||
v4_scan_assembler, device_manager, nth_done_status_mock
|
||||
):
|
||||
scan = _assemble(
|
||||
v4_scan_assembler,
|
||||
device_manager,
|
||||
trigger_source="panda",
|
||||
shutter_delay=0.003,
|
||||
)
|
||||
scan.prepare_scan()
|
||||
scan.fast_axis = mock.MagicMock()
|
||||
scan.panda = mock.MagicMock()
|
||||
scan._premove_motor_status = nth_done_status_mock(resolve_after=1)
|
||||
scan.actions.pre_scan_all_devices = mock.MagicMock()
|
||||
|
||||
scan.pre_scan()
|
||||
|
||||
scan.panda.configure_line.assert_called_once_with(5, 0.1, 0.02, 0.003)
|
||||
scan.panda.arm.assert_called_once_with()
|
||||
|
||||
|
||||
def test_snake_scan_core(v4_scan_assembler, device_manager, nth_done_status_mock):
|
||||
scan = _ready_for_core(
|
||||
_assemble(v4_scan_assembler, device_manager, trigger_source=None), nth_done_status_mock
|
||||
)
|
||||
scan.scan_core()
|
||||
|
||||
line = scan._line
|
||||
moves = [(c.args[0], list(c.args[1])) for c in scan.actions.set.call_args_list]
|
||||
fast, stepper = scan.fast_axis, scan.stepper_axis
|
||||
assert moves == [
|
||||
([fast], [line["line_end"]]),
|
||||
([stepper], [0.0]),
|
||||
([fast], [line["line_start"]]),
|
||||
([stepper], [2.0]),
|
||||
([fast], [line["line_end"]]),
|
||||
]
|
||||
# the PSO window is armed once in pre_scan, not per line; only its range is switched:
|
||||
# parked once each line is triggered, then the window of the next line
|
||||
scan.fast_axis.arm_line_trigger.assert_not_called()
|
||||
scan.fast_axis.disarm_line_trigger.assert_not_called()
|
||||
span = line["line_end"] - line["line_start"]
|
||||
park = mock.call(line["line_end"] + span, line["line_end"] + 2 * span)
|
||||
backward = mock.call(line["line_start"] - span, line["trigger_high"])
|
||||
forward = mock.call(line["trigger_low"], line["line_end"] + span)
|
||||
assert scan.fast_axis.set_line_trigger_window.call_args_list == [
|
||||
park,
|
||||
backward,
|
||||
park,
|
||||
forward,
|
||||
park,
|
||||
]
|
||||
|
||||
|
||||
def test_panda_lines_are_checked(v4_scan_assembler, device_manager, nth_done_status_mock):
|
||||
scan = _ready_for_core(
|
||||
_assemble(v4_scan_assembler, device_manager, trigger_source="panda"), nth_done_status_mock
|
||||
)
|
||||
scan.panda = mock.MagicMock()
|
||||
scan.scan_core()
|
||||
|
||||
lines = [c.args[0] for c in scan.panda.wait_lines_done.call_args_list]
|
||||
assert lines == [1, 2, 3]
|
||||
scan.panda.arm.assert_not_called() # armed once in pre_scan
|
||||
|
||||
|
||||
def test_scan_is_hardware_triggered(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager)
|
||||
scan.prepare_scan()
|
||||
assert scan.scan_info.scan_type == "hardware_triggered"
|
||||
# MCS / detectors: num_points lines x frames_per_trigger frames, armed once
|
||||
assert scan.scan_info.num_points == 3
|
||||
assert scan.scan_info.frames_per_trigger == 5
|
||||
|
||||
|
||||
def test_ddg_scan_core_checks_mcs_frames_per_line(
|
||||
v4_scan_assembler, device_manager, nth_done_status_mock
|
||||
):
|
||||
scan = _ready_for_core(_assemble(v4_scan_assembler, device_manager), nth_done_status_mock)
|
||||
scan.ddg1 = mock.MagicMock()
|
||||
scan.mcs = mock.MagicMock()
|
||||
scan.mcs.current_channel.get.side_effect = [4, 9, 14]
|
||||
scan.scan_core()
|
||||
scan.ddg1.prepare_mcs_on_trigger.assert_not_called() # no arming per line
|
||||
assert scan.mcs.current_channel.get.call_count == 3
|
||||
|
||||
|
||||
def test_ddg_missing_line_trigger_aborts(v4_scan_assembler, device_manager, nth_done_status_mock):
|
||||
scan = _ready_for_core(_assemble(v4_scan_assembler, device_manager), nth_done_status_mock)
|
||||
scan.mcs = mock.MagicMock()
|
||||
scan.mcs.current_channel.get.return_value = 0
|
||||
with pytest.raises(RuntimeError, match="expected 4"):
|
||||
scan._wait_mcs_frames(0, timeout=0.1, poll=0.02)
|
||||
|
||||
|
||||
def test_at_each_point_reads_capture(v4_scan_assembler, device_manager, nth_done_status_mock):
|
||||
scan = _ready_for_core(
|
||||
_assemble(v4_scan_assembler, device_manager, trigger_source=None),
|
||||
nth_done_status_mock,
|
||||
)
|
||||
scan.capture = mock.MagicMock()
|
||||
|
||||
scan.at_each_point([scan.fast_axis], np.array([scan._line["line_end"]]), line_index=0)
|
||||
|
||||
scan.capture.arm.assert_called_once_with(5)
|
||||
# no readout time without trigger hardware: half of 0.5 mm per exposure
|
||||
scan.capture.read_line.assert_called_once_with(centre_shift=pytest.approx(0.25))
|
||||
|
||||
|
||||
def test_statistics_of_a_symmetric_snake(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager, trigger_source=None)
|
||||
scan.prepare_scan()
|
||||
cells = -1.0 + 0.5 * (np.arange(5) + 0.5)
|
||||
fwd = {"enc6_centre": list(cells + 1e-3), "enc2_centre": [0.0] * 5}
|
||||
bwd = {"enc6_centre": list((cells + 3e-3)[::-1]), "enc2_centre": [0.5] * 5}
|
||||
scan._captured_lines = [fwd, bwd, fwd]
|
||||
scan._line_times = [1.0, 1.2, 1.1]
|
||||
scan._readout_times = [0.2, 0.2, 0.2]
|
||||
scan._line_starts = [100.0, 101.5, 103.0] # periods 1.5, 1.5, last line ends at 104.4
|
||||
scan._last_line_end = 104.4
|
||||
text = scan._statistics()
|
||||
assert "forward mean +1.00" in text
|
||||
assert "backward mean +3.00" in text
|
||||
assert "backward - forward [um]: mean +2.00" in text and "peak-peak 0.00" in text
|
||||
assert "lines 4.4 s" in text
|
||||
assert "line period mean 1.47 s (min 1.40, max 1.50)" in text
|
||||
assert "motion and trigger check 1.10 s, capture readout 0.20 s" in text
|
||||
|
||||
|
||||
def test_readout_time_in_scan_info(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager)
|
||||
scan.prepare_scan()
|
||||
assert scan.scan_info.readout_time == pytest.approx(2e-4)
|
||||
|
||||
|
||||
def test_on_exception_restores_ddg_pso_and_axis(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager)
|
||||
scan.prepare_scan()
|
||||
scan.ddg1 = mock.MagicMock()
|
||||
scan.fast_axis = mock.MagicMock()
|
||||
scan._fast_axis_configured = True
|
||||
|
||||
scan.on_exception(RuntimeError("test"))
|
||||
|
||||
scan.ddg1.set_trigger.assert_called_once_with(5)
|
||||
scan.fast_axis.disarm_line_trigger.assert_called_once_with()
|
||||
scan.fast_axis.motor_velocity.put.assert_called_once_with(10.0)
|
||||
scan.fast_axis.wait_in_position.put.assert_called_once_with(True)
|
||||
|
||||
|
||||
def test_restore_continues_after_failing_step(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager, trigger_source="panda")
|
||||
scan.prepare_scan()
|
||||
scan.panda = mock.MagicMock()
|
||||
scan.panda.disarm.side_effect = RuntimeError("panda offline")
|
||||
scan.fast_axis = mock.MagicMock()
|
||||
scan._fast_axis_configured = True
|
||||
|
||||
with pytest.raises(RuntimeError, match="panda offline"):
|
||||
scan.on_exception(RuntimeError("test"))
|
||||
scan.fast_axis.disarm_line_trigger.assert_called_once_with()
|
||||
scan.fast_axis.wait_in_position.put.assert_called_once_with(True)
|
||||
|
||||
|
||||
def test_step_starts_when_fast_axis_left_the_exposures(
|
||||
v4_scan_assembler, device_manager, nth_done_status_mock
|
||||
):
|
||||
scan = _ready_for_core(
|
||||
_assemble(v4_scan_assembler, device_manager, trigger_source=None), nth_done_status_mock
|
||||
)
|
||||
line = scan._line
|
||||
events = []
|
||||
fast_moves = []
|
||||
|
||||
def set_side_effect(motors, positions, **kwargs):
|
||||
if motors == [scan.fast_axis]:
|
||||
status = mock.MagicMock()
|
||||
status.done = False # still running out when the step starts
|
||||
status.wait.side_effect = lambda *a, **k: setattr(status, "done", True)
|
||||
fast_moves.append(status)
|
||||
events.append(("fast", float(positions[0])))
|
||||
return status
|
||||
events.append(("step", float(positions[0]), [s.done for s in fast_moves]))
|
||||
return nth_done_status_mock(resolve_after=1)
|
||||
|
||||
scan.actions.set = mock.MagicMock(side_effect=set_side_effect)
|
||||
# per line: inside the window (triggered), then beyond the exposures (forward: beyond
|
||||
# trigger_high, backward: below trigger_low); the last line only parks the window
|
||||
scan.fast_axis.readback.get.side_effect = [
|
||||
line["line_end"],
|
||||
line["line_end"],
|
||||
line["line_start"],
|
||||
line["line_start"],
|
||||
line["line_end"],
|
||||
]
|
||||
|
||||
scan.scan_core()
|
||||
|
||||
assert events == [
|
||||
("fast", line["line_end"]),
|
||||
("step", 0.0, [False]),
|
||||
("fast", line["line_start"]),
|
||||
("step", 2.0, [True, False]),
|
||||
("fast", line["line_end"]),
|
||||
]
|
||||
span = line["line_end"] - line["line_start"]
|
||||
park = mock.call(line["line_end"] + span, line["line_end"] + 2 * span)
|
||||
assert scan.fast_axis.set_line_trigger_window.call_args_list == [
|
||||
park,
|
||||
mock.call(line["line_start"] - span, line["trigger_high"]),
|
||||
park,
|
||||
mock.call(line["trigger_low"], line["line_end"] + span),
|
||||
park,
|
||||
]
|
||||
|
||||
|
||||
def test_window_parked_once_line_is_triggered(
|
||||
v4_scan_assembler, device_manager, nth_done_status_mock
|
||||
):
|
||||
# the window must be parked while the trigger receiver runs the line (it ignores the
|
||||
# edge then), not at the end of the exposures: that gave a second trigger per line
|
||||
scan = _ready_for_core(
|
||||
_assemble(v4_scan_assembler, device_manager, trigger_source=None), nth_done_status_mock
|
||||
)
|
||||
line = scan._line
|
||||
status = mock.MagicMock()
|
||||
status.done = False
|
||||
scan.actions.set = mock.MagicMock(return_value=status)
|
||||
windows = []
|
||||
scan.fast_axis.set_line_trigger_window.side_effect = lambda *args: windows.append(args)
|
||||
# forward line: run-up (before the window), entered, then beyond the exposures
|
||||
positions = [line["line_start"], line["trigger_low"] + 1e-3, line["trigger_high"] + 1e-3]
|
||||
readbacks = iter(positions)
|
||||
|
||||
def readback():
|
||||
value = next(readbacks)
|
||||
readback.seen.append(value)
|
||||
return value
|
||||
|
||||
readback.seen = []
|
||||
scan.fast_axis.readback.get.side_effect = readback
|
||||
status.wait.side_effect = lambda *a, **k: setattr(status, "done", True)
|
||||
scan._line["num_lines"] = 2
|
||||
|
||||
scan.at_each_point(
|
||||
motors=[scan.fast_axis], positions=np.array([line["line_end"]]), line_index=0
|
||||
)
|
||||
|
||||
span = line["line_end"] - line["line_start"]
|
||||
assert windows == [
|
||||
(line["line_end"] + span, line["line_end"] + 2 * span),
|
||||
(line["line_start"] - span, line["trigger_high"]),
|
||||
]
|
||||
assert readback.seen == positions
|
||||
|
||||
|
||||
def test_ddg_extra_line_trigger_aborts(v4_scan_assembler, device_manager, nth_done_status_mock):
|
||||
scan = _ready_for_core(_assemble(v4_scan_assembler, device_manager), nth_done_status_mock)
|
||||
scan.mcs = mock.MagicMock()
|
||||
scan.mcs.current_channel.get.return_value = 9 # two bursts of 5 frames in line 0
|
||||
with pytest.raises(RuntimeError, match="triggered more than once"):
|
||||
scan._wait_mcs_frames(0, timeout=0.1, poll=0.02)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("attribute", "device_cls"),
|
||||
[
|
||||
("fast_axis", "csaxs_bec.devices.aerotech.a3200.A3200Motor"),
|
||||
("capture", "csaxs_bec.devices.aerotech.a3200.A3200PositionCapture"),
|
||||
("panda", "csaxs_bec.devices.panda_box.panda_line_trigger.PandaLineTrigger"),
|
||||
],
|
||||
)
|
||||
def test_scan_calls_only_user_access_methods(attribute, device_cls):
|
||||
# the scan server reaches device methods through RPC, which only exposes USER_ACCESS
|
||||
import importlib
|
||||
import inspect
|
||||
import re
|
||||
|
||||
from csaxs_bec.scans.scans_v4 import aerotech_cont_grid
|
||||
|
||||
module, name = device_cls.rsplit(".", 1)
|
||||
cls = getattr(importlib.import_module(module), name)
|
||||
source = inspect.getsource(aerotech_cont_grid)
|
||||
called = set(re.findall(rf"self\.{attribute}\.(\w+)\(", source))
|
||||
called |= set(re.findall(rf"self\.{attribute}\.(\w+)\)", source)) # passed as callable
|
||||
signals = {"motor_velocity", "wait_in_position", "max_scan_velocity", "motor_acceleration"}
|
||||
assert called, f"no calls on self.{attribute} found"
|
||||
assert not (called - signals) - set(cls.USER_ACCESS)
|
||||
|
||||
|
||||
def test_minimal_run_up_is_the_ramp_distance_only(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager, settle_time=0.1, minimal_run_up=True)
|
||||
scan.prepare_scan()
|
||||
line = scan._line
|
||||
ramp = (0.5 / 0.1) ** 2 / (2 * 100.0)
|
||||
assert ramp > 0.01
|
||||
assert line["line_start"] == pytest.approx(line["trigger_low"] - ramp)
|
||||
assert line["line_end"] == pytest.approx(line["trigger_high"] + ramp)
|
||||
|
||||
|
||||
def test_minimal_run_up_has_a_floor_at_low_speed(v4_scan_assembler, device_manager):
|
||||
# 0.5 mm/s at 100 mm/s^2: ramp 1.25 um, run-up raised to 10 um
|
||||
_add_devices(device_manager)
|
||||
scan = v4_scan_assembler(
|
||||
"aerotech_cont_grid", "aerox", -1.0, 1.0, 0.01, "aeroy", -2.0, 2.0, 2,
|
||||
exp_time=0.02, minimal_run_up=True,
|
||||
)
|
||||
scan.prepare_scan()
|
||||
line = scan._line
|
||||
assert line["trigger_low"] - line["line_start"] == pytest.approx(0.01)
|
||||
assert line["line_end"] - line["trigger_high"] == pytest.approx(0.01)
|
||||
|
||||
|
||||
def test_minimal_run_up_limit_message(v4_scan_assembler, device_manager):
|
||||
_add_devices(device_manager, max_scan_velocity=50.0)
|
||||
scan = v4_scan_assembler(
|
||||
"aerotech_cont_grid", "aerox", -9.5, 9.5, 0.1, "aeroy", -2.0, 2.0, 2,
|
||||
exp_time=0.005, minimal_run_up=True,
|
||||
)
|
||||
with pytest.raises(LimitError, match="minimal run-up"):
|
||||
scan.prepare_scan()
|
||||
|
||||
|
||||
def test_panda_uses_its_shutter_to_open_delay(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager, trigger_source="panda")
|
||||
scan.prepare_scan()
|
||||
line = scan._line
|
||||
velocity = 0.5 / 0.1
|
||||
assert line["shutter_open_delay"] == pytest.approx(8e-3)
|
||||
# trigger one shutter delay of travel before the first cell (gap: readout 0.02 s)
|
||||
assert line["trigger_low"] == pytest.approx(-1.0 - velocity * 8e-3 + velocity * 0.02 / 2)
|
||||
|
||||
|
||||
def test_ddg_shutter_delay_argument_is_set_on_ddg1_and_restored(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager, shutter_delay=0.012)
|
||||
scan.prepare_scan()
|
||||
assert scan._line["shutter_open_delay"] == pytest.approx(0.012)
|
||||
scan.ddg1.set_shutter_to_open_delay.assert_called_once_with(0.012)
|
||||
scan.fast_axis = mock.MagicMock()
|
||||
scan.on_exception(RuntimeError("aborted"))
|
||||
assert scan.ddg1.set_shutter_to_open_delay.call_args_list[-1] == mock.call(2e-3)
|
||||
|
||||
|
||||
def test_ddg_without_shutter_delay_argument_leaves_ddg1_alone(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager)
|
||||
scan.prepare_scan()
|
||||
scan.fast_axis = mock.MagicMock()
|
||||
scan.on_exception(RuntimeError("aborted"))
|
||||
scan.ddg1.set_shutter_to_open_delay.assert_not_called()
|
||||
|
||||
|
||||
def _progress_devices(scan):
|
||||
return [
|
||||
instr["device_progress"][0]
|
||||
for instr in scan.scan_info.scan_report_instructions
|
||||
if "device_progress" in instr
|
||||
]
|
||||
|
||||
|
||||
def test_progress_bar_from_the_mcs_at_the_beamline(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager)
|
||||
scan.prepare_scan()
|
||||
assert _progress_devices(scan) == ["mcs"]
|
||||
|
||||
|
||||
def test_progress_bar_from_the_capture_without_mcs(v4_scan_assembler, device_manager):
|
||||
# a progress display is what prints the statistics (client messages) at the end
|
||||
scan = _assemble(v4_scan_assembler, device_manager, trigger_source="panda")
|
||||
scan.prepare_scan()
|
||||
assert _progress_devices(scan) == ["aerocapture"]
|
||||
@@ -36,6 +36,7 @@ nav = [
|
||||
{ "SAXS" = [
|
||||
{ "Overview" = "user/saxs/saxs.md" },
|
||||
{ "Online integration" = "user/saxs/online_integration.md" },
|
||||
{ "Aerotech stages" = "user/saxs/aerotech_stages/aerotech_stages.md" },
|
||||
] },
|
||||
{ "Data analysis" = [
|
||||
{ "Overview" = "user/data_analysis/data_analysis.md" },
|
||||
@@ -48,6 +49,7 @@ nav = [
|
||||
{ "LamNI smear aid architecture" = "developer/lamni_smear_architecture.md" },
|
||||
{ "PandaBox free-running setup" = "developer/panda_box_free_running_setup.md" },
|
||||
{ "PandaBox data schema" = "developer/panda_box_free_running_data_schema.md" },
|
||||
{ "Aerotech stages: design and triggering" = "developer/aerotech_stages.md" },
|
||||
] },
|
||||
]
|
||||
|
||||
|
||||