Feat/aerotech stages #351

Merged
holler merged 53 commits from feat/aerotech-stages into main 2026-11-03 11:44:23 +01:00
33 changed files with 6907 additions and 7 deletions

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
+5 -2
View File
@@ -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
+197
View File
@@ -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.
Whitespace-only changes.
File diff suppressed because it is too large. Load diff
@@ -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
+508
View File
@@ -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()
+11 -1
View File
@@ -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
+68 -4
View File
@@ -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()
+13
View File
@@ -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
+1
View File
@@ -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]
+220
View File
@@ -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):
![2 x 2 mm, 100 um / 5 ms (20 mm/s): the oscillation after the ramp](run_up_deviation_2x2_100um_5ms.png)
![3.5 x 3.0 mm, 100 um / 5 ms (20 mm/s)](run_up_deviation_3p5x3_100um_5ms.png)
![2 x 2 mm, 40 um / 20 ms (2 mm/s): constant offset after the first cells](run_up_deviation_2x2_40um_20ms.png)
![1 x 1 mm, 10 um / 20 ms (0.5 mm/s)](run_up_deviation_1x1_10um_20ms.png)
Measured exposure centres of the first cells of the first lines against the nominal
cell centres (crosses), forward lines blue, backward lines orange:
![2 x 2 mm, 100 um / 5 ms (20 mm/s)](run_up_zoom_2x2_100um_5ms.png)
![2 x 2 mm, 40 um / 20 ms (2 mm/s)](run_up_zoom_2x2_40um_20ms.png)
![1 x 1 mm, 10 um / 20 ms (0.5 mm/s)](run_up_zoom_1x1_10um_20ms.png)
### 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+5
View File
@@ -8,6 +8,11 @@ Welcome to the SAXS section of the cSAXS beamline.
Setup of online radial integration on Jungfraujoch.
- ![](../../assets/biotech.svg){ 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()
+515
View File
@@ -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"]
+2
View File
@@ -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" },
] },
]