diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/aerotech.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/aerotech.py new file mode 100644 index 00000000..22882e59 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/aerotech.py @@ -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) diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py index d0cc7ae1..8b2be60b 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py @@ -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) diff --git a/csaxs_bec/device_configs/legacy/sastt_aerotech_panda.yaml b/csaxs_bec/device_configs/legacy/sastt_aerotech_panda.yaml new file mode 100644 index 00000000..293719a7 --- /dev/null +++ b/csaxs_bec/device_configs/legacy/sastt_aerotech_panda.yaml @@ -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 diff --git a/csaxs_bec/device_configs/main.yaml b/csaxs_bec/device_configs/main.yaml index 7a45e202..944a0c1f 100644 --- a/csaxs_bec/device_configs/main.yaml +++ b/csaxs_bec/device_configs/main.yaml @@ -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 diff --git a/csaxs_bec/device_configs/sastt_aerotech.yaml b/csaxs_bec/device_configs/sastt_aerotech.yaml new file mode 100644 index 00000000..861a99de --- /dev/null +++ b/csaxs_bec/device_configs/sastt_aerotech.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 diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_sastt_aerotech.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_sastt_aerotech.yaml new file mode 100644 index 00000000..d81a613d --- /dev/null +++ b/csaxs_bec/device_configs/simulated_omny/simulated_sastt_aerotech.yaml @@ -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 diff --git a/csaxs_bec/devices/aerotech/README.md b/csaxs_bec/devices/aerotech/README.md new file mode 100644 index 00000000..ac20b489 --- /dev/null +++ b/csaxs_bec/devices/aerotech/README.md @@ -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 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`, start + of each exposure) and as exposure centres (`aerocapture_positions_enc_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. diff --git a/csaxs_bec/devices/aerotech/__init__.py b/csaxs_bec/devices/aerotech/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/csaxs_bec/devices/aerotech/a3200.py b/csaxs_bec/devices/aerotech/a3200.py new file mode 100644 index 00000000..d7f4ff67 --- /dev/null +++ b/csaxs_bec/devices/aerotech/a3200.py @@ -0,0 +1,1001 @@ +""" +Aerotech A3200 motion controller (Npaq drive rack) via the A3200 ASCII command interface. + +The Npaq is commanded over FireWire by the A3200 software (SMC) running on a Windows PC. +BEC talks to that PC through the ASCII command interface, a line-based TCP protocol: + +- a command is an AeroBasic line (e.g. ``MOVEABS X 5 10``) or a server command (``~STATUS ...``) +- the reply starts with one character: ``%`` success, ``!`` invalid syntax, ``#`` task fault +- data follows the success character (``%1.234``), the reply is terminated by EOS (``\\n``) + +Required controller configuration (Configuration Manager, then reset the controller): + +- CommandSetup = 0x00010004 (ASCII interface enabled + "Always Send EOS") +- CommandPort = 8000 (default used here) +- the default command characters: EOS ``\\n``, success ``%``, invalid ``!``, fault ``#`` + +Blocking AeroBasic commands (HOME, WAIT ...) block the socket until they finish, so only +asynchronous commands are used for motion (MOVEABS, HOMEASYNC, ABORT). +""" + +import threading +import time + +import numpy as np +from bec_lib import bec_logger +from ophyd import Component as Cpt +from ophyd import Device, PositionerBase, Signal +from ophyd.status import wait as status_wait +from ophyd.utils import LimitError, ReadOnlyError +from ophyd_devices import AsyncMultiSignal, ProgressSignal, PSIDeviceBase +from ophyd_devices.utils.controller import Controller, retry_once, threadlocked +from ophyd_devices.utils.socket import SocketIO, SocketSignal, raise_if_disconnected +from prettytable import PrettyTable + +logger = bec_logger.logger + +EOS = "\n" +ACK_CHAR = "%" +NAK_CHAR = "!" +FAULT_CHAR = "#" + +# Axis Status bits (A3200 help: Motion Composer Suite > System Diagnostics > Axis Status) +AXISSTATUS_HOMED = 1 << 0 +AXISSTATUS_PROFILING = 1 << 1 +AXISSTATUS_HOMING = 1 << 4 +AXISSTATUS_ENABLING = 1 << 5 +AXISSTATUS_JOGGING = 1 << 8 +AXISSTATUS_DRIVE_PENDING = 1 << 9 +AXISSTATUS_MOVE_DONE = 1 << 22 + +AXISSTATUS_BUSY_MASK = ( + AXISSTATUS_PROFILING + | AXISSTATUS_HOMING + | AXISSTATUS_ENABLING + | AXISSTATUS_JOGGING + | AXISSTATUS_DRIVE_PENDING +) + +# Drive Status bits +DRIVESTATUS_ENABLED = 1 << 0 +DRIVESTATUS_CW_EOT_LIMIT = 1 << 1 +DRIVESTATUS_CCW_EOT_LIMIT = 1 << 2 +DRIVESTATUS_HOME_LIMIT = 1 << 3 +DRIVESTATUS_IN_POSITION = 1 << 25 # InPositionDistance / InPositionTime +DRIVESTATUS_ENCODER_CLIPPING = 1 << 29 # encoder multiplier input signals out of range + +# Axis Fault bits +AXIS_FAULT_NAMES = { + 0: "PositionError", + 1: "OverCurrent", + 2: "CwEOTLimit", + 3: "CcwEOTLimit", + 4: "CwSoftLimit", + 5: "CcwSoftLimit", + 6: "AmplifierFault", + 7: "PositionFbk", + 8: "VelocityFbk", + 9: "HallFault", + 10: "MaxVelocity", + 11: "EstopFault", + 12: "VelocityError", + 14: "ProbeFault", + 15: "ExternalFault", + 17: "MotorTemp", + 18: "AmplifierTemp", + 19: "EncoderFault", + 20: "CommLost", + 22: "GantryMisalign", + 23: "FbkScalingFault", + 24: "MrkSearchFault", + 25: "SafeZoneFault", + 26: "InPosTimeout", + 27: "VoltageClamp", + 28: "PowerSupply", + 29: "MissedInterrupt", + 30: "Internal", +} + + +def decode_axis_fault(fault: int) -> list[str]: + """Return the names of all fault bits set in an Axis Fault word.""" + return [name for bit, name in AXIS_FAULT_NAMES.items() if fault & (1 << bit)] + + +class A3200CommunicationError(Exception): + pass + + +class A3200Error(Exception): + pass + + +class A3200Controller(Controller): + """ + Controller for an Aerotech A3200 system, one instance per (host, port). + + Axes are addressed by their A3200 axis name (e.g. "X", "Y", "U"). The axis_nr given to + set_axis is only the slot in the controller's axis list. + """ + + _axes_per_controller = 32 + USER_ACCESS = [ + "describe", + "socket_put_and_receive", + "command", + "get_version", + "enable_axis", + "disable_axis", + "home_axis", + "fault_ack", + "abort_axis", + "abort_all_axes", + "axis_status", + "print_command_history", + ] + + # $global variables used by BEC: one scratch variable for evaluate() and a block + # for drive array reads. The controller has GlobalDoubles of them (255 on SAXS-TT, + # i.e. $global[0..254]). + scratch_global = 254 + array_global_base = 0 + array_read_chunk = 200 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not hasattr(self, "_rx_buffer"): + self._rx_buffer = "" + self._counts_per_unit = {} + self._pso_reference = {} # axis -> (position at PSOWINDOW LOAD, PSO counts per unit) + + @threadlocked + def socket_put(self, val: str) -> None: + self.command_history.append(f"[PUT]: {val}") + self.sock.put(f"{val}{EOS}".encode()) + + @threadlocked + def socket_get(self, timeout: float = 5.0) -> str: + """Receive one EOS-terminated reply. The EOS character is stripped.""" + start = time.time() + while EOS not in self._rx_buffer: + chunk = self.sock.receive().decode() + if chunk: + self._rx_buffer += chunk + continue + if time.time() - start > timeout: + partial, self._rx_buffer = self._rx_buffer, "" + raise A3200CommunicationError( + f"No complete reply within {timeout} s. Received so far:" + f" {partial!r}. Is 'Always Send EOS' set in CommandSetup?" + ) + time.sleep(0.005) + response, self._rx_buffer = self._rx_buffer.split(EOS, 1) + self.command_history.append(f"[GET]: {response}") + return response + + @threadlocked + def socket_put_and_receive(self, val: str, remove_trailing_chars=True) -> str: + self.socket_put(val) + return self.socket_get() + + @threadlocked + def command(self, cmd: str) -> str: + """ + Send a command and return the reply data (without the success character). + Not retried: a motion command must not be sent twice. + + Raises: + A3200Error: invalid command ('!') or task fault ('#'); for a fault the + controller's ~LASTERROR message is included. + A3200CommunicationError: unexpected reply. + """ + reply = self.socket_put_and_receive(cmd) + if not reply: + raise A3200CommunicationError(f"Empty reply to '{cmd}'.") + code, data = reply[0], reply[1:].strip() + if code == ACK_CHAR: + return data + if code == NAK_CHAR: + raise A3200Error(f"Controller rejected '{cmd}' as invalid.") + if code == FAULT_CHAR: + raise A3200Error(f"'{cmd}' failed: {self.last_error()}") + raise A3200CommunicationError(f"Unexpected reply to '{cmd}': {reply!r}") + + def last_error(self) -> str: + reply = self.socket_put_and_receive("~LASTERROR") + return reply[1:].strip() if reply.startswith(ACK_CHAR) else reply + + def get_version(self) -> str: + return self.command("~VERSION") + + @retry_once + @threadlocked + def get_status_items(self, items: list[tuple]) -> list[float]: + """ + Read several status items in one round trip with ~STATUS. + + Args: + items: tuples as in the ~STATUS syntax, e.g. [("X", "PositionFeedback")] + """ + query = " ".join(f"({', '.join(str(i) for i in item)})" for item in items) + values = self.command(f"~STATUS {query}").split() + if len(values) != len(items): + raise A3200CommunicationError( + f"Expected {len(items)} values for ~STATUS {query}, received {values}" + ) + return [float(v) for v in values] + + def axis_status(self, axis: str) -> dict: + """Position, status words and decoded flags of one axis.""" + position, axis_status, drive_status, axis_fault = self.get_status_items( + [ + (axis, "ProgramPositionFeedback"), + (axis, "AxisStatus"), + (axis, "DriveStatus"), + (axis, "AxisFault"), + ] + ) + axis_status, drive_status, axis_fault = ( + int(axis_status), + int(drive_status), + int(axis_fault), + ) + return { + "position": position, + "axis_status": axis_status, + "drive_status": drive_status, + "axis_fault": axis_fault, + "enabled": bool(drive_status & DRIVESTATUS_ENABLED), + "homed": bool(axis_status & AXISSTATUS_HOMED), + "moving": self._is_moving(axis_status), + "faults": decode_axis_fault(axis_fault), + "in_position": bool(drive_status & DRIVESTATUS_IN_POSITION), + "cw_limit": bool(drive_status & DRIVESTATUS_CW_EOT_LIMIT), + "ccw_limit": bool(drive_status & DRIVESTATUS_CCW_EOT_LIMIT), + "encoder_clipping": bool(drive_status & DRIVESTATUS_ENCODER_CLIPPING), + } + + def position_error(self, axis: str) -> float: + """Position command minus position feedback of one axis, in user units.""" + (error,) = self.get_status_items([(axis, "PositionError")]) + return error + + @staticmethod + def _is_moving(axis_status: int) -> bool: + return bool(axis_status & AXISSTATUS_BUSY_MASK) or not axis_status & AXISSTATUS_MOVE_DONE + + def is_axis_moving(self, axis: str) -> bool: + (axis_status,) = self.get_status_items([(axis, "AxisStatus")]) + return self._is_moving(int(axis_status)) + + def get_position(self, axis: str) -> float: + (position,) = self.get_status_items([(axis, "ProgramPositionFeedback")]) + return position + + def check_ready_to_move(self, axis: str, require_homed: bool = True) -> None: + """Raise A3200Error if the axis is faulted, disabled or (optionally) not homed.""" + status = self.axis_status(axis) + if status["faults"]: + raise A3200Error( + f"Axis {axis} has faults {status['faults']}. Clear them with fault_ack('{axis}')." + ) + if not status["enabled"]: + raise A3200Error(f"Axis {axis} is not enabled. Use enable_axis('{axis}').") + if require_homed and not status["homed"]: + raise A3200Error(f"Axis {axis} is not homed. Use home_axis('{axis}').") + + def move_absolute(self, axis: str, position: float, velocity: float) -> None: + """Start an asynchronous absolute move (MOVEABS returns before the move is done).""" + if velocity <= 0: + raise ValueError(f"Velocity must be positive, received {velocity}.") + self.command(f"MOVEABS {axis} {position:.6f} {velocity:.6f}") + + def enable_axis(self, axis: str) -> None: + self.command(f"ENABLE {axis}") + + def disable_axis(self, axis: str) -> None: + self.command(f"DISABLE {axis}") + + def home_axis(self, axis: str, wait: bool = True, timeout: float = 300) -> None: + """Home an axis with HOMEASYNC (non-blocking on the socket), optionally wait.""" + self.command(f"HOMEASYNC {axis}") + if not wait: + return + start = time.time() + time.sleep(0.1) + while self.is_axis_moving(axis): + if time.time() - start > timeout: + self.abort_axis(axis) + raise A3200Error(f"Homing of axis {axis} did not finish within {timeout} s.") + time.sleep(0.1) + status = self.axis_status(axis) + if not status["homed"]: + raise A3200Error(f"Homing of axis {axis} failed. Faults: {status['faults']}") + + def fault_ack(self, axis: str) -> None: + self.command(f"FAULTACK {axis}") + + def abort_axis(self, axis: str) -> None: + self.command(f"ABORT {axis}") + + def abort_all_axes(self) -> None: + axes = [ax.axis_Id for ax in self._axis if ax is not None] + if axes: + self.command(f"ABORT {' '.join(axes)}") + + def set_ramp_rate(self, axis: str, rate: float) -> None: + """Rate-based ramping for axis-based moves (MOVEABS), rate in user units/s^2.""" + self.command(f"RAMP MODE RATE {axis}") + self.command(f"RAMP RATE {axis} {rate:.6f}") + + # --- global variables (scratch space for functions and array reads) ----------- + + def get_globals(self, start: int, count: int) -> list[float]: + """Read $global[start] ... $global[start + count - 1] in one ~STATUS round trip.""" + return self.get_status_items([(f"$global[{i}]",) for i in range(start, start + count)]) + + def evaluate(self, expression: str) -> float: + """Evaluate an AeroBasic expression on the controller, e.g. UNITSTOCOUNTS(X, 1).""" + self.command(f"$global[{self.scratch_global}] = {expression}") + return self.get_globals(self.scratch_global, 1)[0] + + def counts_per_unit(self, axis: str) -> float: + """Encoder counts per user unit of an axis (cached, it is a fixed parameter).""" + if axis not in self._counts_per_unit: + self._counts_per_unit[axis] = self.evaluate(f"UNITSTOCOUNTS({axis}, 1)") + return self._counts_per_unit[axis] + + # --- PSO (position synchronized output) ----------------------------------- + + def pso_arm_window( + self, axis: str, pso_input: int, low: float, high: float, counts_divider: float = 1 + ) -> None: + """ + PSO output high while the axis is inside [low, high] (user units, absolute). + + The window counter is loaded with the current position (axis at rest), so the + range is given relative to it in counts of the PSO input: CountsPerUnit, divided by + the EmulatedQuadratureDivider for the MXR emulated quadrature inputs 6-8. + """ + cpu = self.counts_per_unit(axis) / counts_divider + self._pso_reference[axis] = (self.get_position(axis), cpu) + low_counts, high_counts = self._pso_window_counts(axis, low, high) + self.command(f"PSOCONTROL {axis} RESET") + self.command(f"PSOWINDOW {axis} 1 INPUT {pso_input}") + self.command(f"PSOWINDOW {axis} 1 LOAD 0") + self.command(f"PSOWINDOW {axis} 1 RANGE {low_counts}, {high_counts}") + self.command(f"PSOOUTPUT {axis} WINDOW") + self.command(f"PSOCONTROL {axis} ARM") + + def pso_window_range(self, axis: str, low: float, high: float) -> None: + """ + Move the armed window to [low, high] (user units, absolute). The window counter is + not reloaded: the position reference of pso_arm_window stays valid, the axis may be + in motion. + """ + if axis not in self._pso_reference: + raise A3200Error(f"PSO window of axis {axis} is not armed.") + low_counts, high_counts = self._pso_window_counts(axis, low, high) + self.command(f"PSOWINDOW {axis} 1 RANGE {low_counts}, {high_counts}") + + def _pso_window_counts(self, axis: str, low: float, high: float) -> tuple[int, int]: + reference, cpu = self._pso_reference[axis] + return tuple( + sorted((int(round((low - reference) * cpu)), int(round((high - reference) * cpu)))) + ) + + def pso_off(self, axis: str) -> None: + self.command(f"PSOWINDOW {axis} 1 OFF") + self.command(f"PSOCONTROL {axis} OFF") + self._pso_reference.pop(axis, None) + + # --- DATAACQ (position capture into the drive array) ----------------------- + + def dataacq_arm(self, axis: str, trigger: int, channels: list[int], n_triggers: int) -> None: + """ + Capture the source signals `channels` on each trigger into the drive array, + starting at index 0. Npaq trigger 0 / 3 = Input 12 (UINT1) "rising" / "falling" edge + (measured: 3 latches when the TTL level at J12 rises), 6 = PSO firing. + Npaq sources: 0-5 local encoder counter, 6-11 encoder multiplier (MXR) channel 0-5. + """ + self.command(f"DATAACQ {axis} 1 OFF") + self.command(f"DATAACQ {axis} 1 TRIGGER {trigger}") + for channel in sorted(channels): + self.command(f"DATAACQ {axis} 1 INPUT {channel}") + self.command(f"DATAACQ {axis} 1 ARRAY 0, {n_triggers * len(channels)}") + + def dataacq_off(self, axis: str) -> None: + self.command(f"DATAACQ {axis} 1 OFF") + + def read_drive_array(self, axis: str, start: int, count: int) -> list[float]: + """Read drive array elements through $global[array_global_base ...] in chunks.""" + values = [] + chunk = self.array_read_chunk + for offset in range(0, count, chunk): + n = min(chunk, count - offset) + self.command( + f"ARRAY {axis} READ $global[{self.array_global_base}] {start + offset} {n} FAST" + ) + values.extend(self.get_globals(self.array_global_base, n)) + return values + + def describe(self) -> None: + t = PrettyTable() + t.title = f"{self.__class__.__name__} on {self.sock.host}:{self.sock.port}" + t.field_names = ["Axis", "Name", "Enabled", "Homed", "Moving", "Faults", "Position"] + for axis in self._axis: + if axis is None: + continue + status = self.axis_status(axis.axis_Id) + t.add_row( + [ + axis.axis_Id, + axis.name, + status["enabled"], + status["homed"], + status["moving"], + ", ".join(status["faults"]) or "-", + f"{status['position'] * axis.sign:.4f}", + ] + ) + print(t) + + +class A3200SignalBase(SocketSignal): + def __init__(self, signal_name, **kwargs): + self.signal_name = signal_name + super().__init__(**kwargs) + self.controller = self.root.controller if hasattr(self.root, "controller") else None + + +class A3200SignalRO(A3200SignalBase): + def __init__(self, signal_name, **kwargs): + super().__init__(signal_name, **kwargs) + self._metadata["write_access"] = False + + def _socket_set(self, val): + raise ReadOnlyError("Read-only signals cannot be set") + + +class A3200ReadbackSignal(A3200SignalRO): + @retry_once + @threadlocked + def _socket_get(self) -> float: + return self.controller.get_position(self.parent.axis_Id) * self.parent.sign + + def read(self): + self._metadata["timestamp"] = time.time() + return super().read() + + +class A3200SetpointSignal(A3200SignalBase): + def __init__(self, signal_name, **kwargs): + super().__init__(signal_name, **kwargs) + self.setpoint = 0.0 + + def _socket_get(self) -> float: + """Last setpoint sent; it is not read back from the controller.""" + return self.setpoint + + @threadlocked + def _socket_set(self, val: float) -> None: + self.setpoint = val + acceleration = self.parent.motor_acceleration.get() + if acceleration: + self.controller.set_ramp_rate(self.parent.axis_Id, acceleration) + self.controller.move_absolute( + self.parent.axis_Id, val * self.parent.sign, self.parent.motor_velocity.get() + ) + + +class A3200MotorIsMoving(A3200SignalRO): + @threadlocked + def _socket_get(self) -> bool: + return self.controller.is_axis_moving(self.parent.axis_Id) + + def get(self): + val = super().get() + if val is not None: + self._run_subs(sub_type=self.SUB_VALUE, value=val, timestamp=time.time()) + return val + + +class A3200AxisHomed(A3200SignalRO): + @threadlocked + def _socket_get(self) -> bool: + return self.controller.axis_status(self.parent.axis_Id)["homed"] + + +class A3200Motor(Device, PositionerBase): + """ + One axis of an Aerotech A3200 controller. + + Args: + axis_Id: A3200 axis name, e.g. "X". + axis_nr: slot of this axis in the controller's axis list (unique per controller). + velocity: move speed in user units per second (A3200 user units: mm or deg). + egu: engineering units shown in BEC. + require_homed: refuse moves while the axis is not homed. + tolerance: allowed deviation from the target after the move, in user units. + wait_in_position (signal): False makes a move finish when the motion profile ends, + without waiting for In Position and without the tolerance check (used by + scans for the turnarounds of the fast axis). Default True. + in_position_timeout: after the motion profile ends, wait this long (s) for the + position error to settle within tolerance before the position is compared with + the target. The controller's In Position status is not used: its + InPositionDistance can be tighter than the axis settles (X: 10 nm). + in_position_hold: position error and distance to the target must stay within + tolerance this long (s) before the move counts as settled. Default 0.1 s: after + a hard stop X rings at ~10 Hz, a single reading can be a zero crossing. + acceleration: ramp rate for moves in user units/s^2; None keeps the controller's + ramp settings (DefaultRampRate). + max_scan_velocity: highest velocity a continuous scan may set on this axis, in user + units per second; None: velocity. The scan restores velocity afterwards, also + after an aborted scan. + pso_input: PSO input of this axis, needed for the PSO line trigger + (arm_line_trigger). Npaq: 0-5 local encoder counter (square wave encoders), + 6-8 MXR emulated quadrature (sine encoders, see EmulatedQuadratureChannel). + pso_counts_divider: EmulatedQuadratureDivider of the axis when pso_input is 6-8, + PSO counts = position * CountsPerUnit / pso_counts_divider. + """ + + USER_ACCESS = [ + "controller", + "enable", + "disable", + "home", + "fault_ack", + "status", + "arm_line_trigger", + "set_line_trigger_window", + "disarm_line_trigger", + ] + readback = Cpt(A3200ReadbackSignal, signal_name="readback", kind="hinted") + user_setpoint = Cpt(A3200SetpointSignal, signal_name="setpoint") + motor_is_moving = Cpt(A3200MotorIsMoving, signal_name="motor_is_moving", kind="normal") + homed = Cpt(A3200AxisHomed, signal_name="homed", kind="config") + motor_velocity = Cpt(Signal, value=1.0, kind="config") + motor_acceleration = Cpt(Signal, value=0.0, kind="config") + max_scan_velocity = Cpt(Signal, value=1.0, kind="config") + wait_in_position = Cpt(Signal, value=True, kind="config") + high_limit_travel = Cpt(Signal, value=0, kind="omitted") + low_limit_travel = Cpt(Signal, value=0, kind="omitted") + + SUB_READBACK = "readback" + SUB_CONNECTION_CHANGE = "connection_change" + _default_sub = SUB_READBACK + + def __init__( + self, + axis_Id, + prefix="", + *, + name, + axis_nr=0, + kind=None, + read_attrs=None, + configuration_attrs=None, + parent=None, + host="localhost", + port=8000, + limits=None, + sign=1, + velocity=1.0, + egu="mm", + tolerance=0.001, + in_position_timeout=2.0, + in_position_hold=0.1, + require_homed=True, + acceleration=None, + max_scan_velocity=None, + pso_input=None, + pso_counts_divider=1, + socket_cls=SocketIO, + device_manager=None, + **kwargs, + ): + self.controller = A3200Controller( + socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager + ) + self.axis_Id = axis_Id + self.axis_nr = axis_nr + self.controller.set_axis(axis=self, axis_nr=axis_nr) + self.sign = sign + self.tolerance = tolerance + self.in_position_timeout = in_position_timeout + self.in_position_hold = in_position_hold + self.require_homed = require_homed + self.pso_input = pso_input + self.pso_counts_divider = pso_counts_divider + self._egu = egu + self.device_manager = device_manager + + super().__init__( + prefix, + name=name, + kind=kind, + read_attrs=read_attrs, + configuration_attrs=configuration_attrs, + parent=parent, + **kwargs, + ) + self.readback.name = self.name + self.motor_velocity.put(velocity) + self.motor_acceleration.put(acceleration or 0.0) + self.max_scan_velocity.put(max_scan_velocity or velocity) + self.controller.subscribe( + self._update_connection_state, event_type=self.SUB_CONNECTION_CHANGE + ) + self._update_connection_state() + + if limits is not None: + assert len(limits) == 2 + self.low_limit_travel.put(limits[0]) + self.high_limit_travel.put(limits[1]) + + def wait_for_connection(self, timeout: float = 30.0) -> bool: + self.controller.on(timeout=timeout) + self.user_setpoint.setpoint = self.readback.get() + + def destroy(self): + """Make sure to turn off the controller socket on destroy.""" + self.controller.off(update_config=False) + return super().destroy() + + @property + def limits(self): + return (self.low_limit_travel.get(), self.high_limit_travel.get()) + + @property + def low_limit(self): + return self.limits[0] + + @property + def high_limit(self): + return self.limits[1] + + @property + def egu(self): + return self._egu + + def check_value(self, pos): + """Check that the position is within the soft limits""" + low_limit, high_limit = self.limits + if low_limit < high_limit and not (low_limit <= pos <= high_limit): + raise LimitError(f"position={pos} not within limits {self.limits}") + + def _update_connection_state(self, **kwargs): + for walk in self.walk_signals(): + walk.item._metadata["connected"] = self.controller.connected + + @raise_if_disconnected + def move(self, position, wait=True, **kwargs): + """Move to a position. Returns a MoveStatus that finishes when the axis is done.""" + # Check before the MoveStatus exists: a failed MoveStatus calls stop() (ABORT) from + # a callback thread, which could abort a move or homing started right after. + self.check_value(position) + self.controller.check_ready_to_move(self.axis_Id, require_homed=self.require_homed) + self._started_moving = False + timeout = kwargs.pop("timeout", 100) + status = super().move(position, timeout=timeout, **kwargs) + try: + self.user_setpoint.put(position, wait=False) + except Exception: + self._done_moving(success=False) + raise + + def move_and_finish(): + # MOVEABS is asynchronous; give the controller one cycle to set the busy bits + time.sleep(0.02) + last_readback = 0.0 + while self.motor_is_moving.get(): + if time.time() - last_readback > 0.1: + val = self.readback.read() + self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time()) + last_readback = time.time() + time.sleep(0.02) + if not self.wait_in_position.get(): + val = self.readback.read() + self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time()) + faults = self.controller.axis_status(self.axis_Id)["faults"] + if faults: + logger.warning(f"{self.name} stopped with faults {faults}.") + self._done_moving(success=not faults) + return + # the profile is done, the axis may still be settling: after a hard stop X rings + # at ~10 Hz with ~20 um, so a single small position error can be a zero crossing. + # Settled = position error and distance to the target within the tolerance for + # in_position_hold without interruption. + deadline = time.time() + self.in_position_timeout + settled_since = None + while True: + now = time.time() + in_tolerance = ( + abs(self.controller.position_error(self.axis_Id)) <= self.tolerance + and abs(self.readback.get() - position) <= self.tolerance + ) + if not in_tolerance: + settled_since = None + elif settled_since is None: + settled_since = now + if settled_since is not None and now - settled_since >= self.in_position_hold: + break + if now > deadline: + logger.warning( + f"{self.name}: not settled within {self.tolerance} {self.egu} for" + f" {self.in_position_hold} s, {self.in_position_timeout} s after the end" + " of the move." + ) + break + time.sleep(0.02) + val = self.readback.read() + self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time()) + success = np.isclose(val[self.name]["value"], position, atol=self.tolerance) + if not success: + faults = self.controller.axis_status(self.axis_Id)["faults"] + logger.warning( + f"{self.name} stopped at {val[self.name]['value']} instead of {position}." + f" Faults: {faults}" + ) + self._done_moving(success=success) + + threading.Thread(target=move_and_finish, daemon=True).start() + try: + if wait: + status_wait(status) + except KeyboardInterrupt: + self.stop() + raise + + return status + + def stop(self, *, success=False): + self.controller.abort_axis(self.axis_Id) + return super().stop(success=success) + + def enable(self) -> None: + self.controller.enable_axis(self.axis_Id) + + def disable(self) -> None: + self.controller.disable_axis(self.axis_Id) + + def home(self, wait: bool = True) -> None: + self.controller.home_axis(self.axis_Id, wait=wait) + val = self.readback.read() + self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time()) + + def fault_ack(self) -> None: + self.controller.fault_ack(self.axis_Id) + + def arm_line_trigger(self, start: float, end: float) -> None: + """ + Arm the PSO output to go high when the axis enters [start, end] and low when it + leaves it. The rising edge starts the exposure sequence of a scan line. The axis + must be at rest outside the window. + """ + if self.pso_input is None: + raise A3200Error(f"{self.name}: pso_input is not configured, no PSO trigger.") + self.controller.pso_arm_window( + self.axis_Id, + self.pso_input, + start * self.sign, + end * self.sign, + counts_divider=self.pso_counts_divider, + ) + + def set_line_trigger_window(self, start: float, end: float) -> None: + """Move the window of the armed line trigger to [start, end], keeping its reference.""" + self.controller.pso_window_range(self.axis_Id, start * self.sign, end * self.sign) + + def disarm_line_trigger(self) -> None: + self.controller.pso_off(self.axis_Id) + + def status(self) -> dict: + return self.controller.axis_status(self.axis_Id) + + +# Npaq DATAACQ position sources: 0-5 local encoder counter, 6-11 encoder multiplier (MXR) +# channel 0-5 (max. 4 kHz trigger rate); 12-15 are analog inputs, not used here. +DATAACQ_POSITION_SOURCES = 12 +# Npaq DATAACQ triggers on UINT1 (J12 pin 1+/2-). Measured 2026-10-29 with a TTL gate on +# J12 (PandA TTLOUT3, X at 2 mm/s): trigger 3 ("falling edge" in the A3200 help) latches at +# the rising TTL edge (start of the exposure gate), trigger 0 ("rising edge") at the falling +# TTL edge (end of the gate). The opto input is inverted compared with the help. +DATAACQ_TRIGGER_UINT1_TTL_RISING = 3 +DATAACQ_TRIGGER_UINT1_TTL_FALLING = 0 +DATAACQ_TRIGGER_PSO_FIRING_EVENT = 6 + + +class A3200PositionCapture(PSIDeviceBase): + """ + Latch axis positions on an external trigger (Npaq DATAACQ, drive array). + + Each trigger edge (default: rising TTL edge on Input 12 / UINT1, i.e. the start of the + detector exposure gate) stores the encoder counters of the configured channels in the + drive array. After a scan line, read_line() reads them back, converts them to user + units (CountsPerUnit of the axis) and publishes them as async data (signals enc). + + Args: + channels: DATAACQ source -> A3200 axis name, e.g. {6: "X", 2: "Y"}. Npaq sources: + 0-5 local encoder counter (square wave encoders), 6-11 encoder multiplier (MXR) + channel 0-5 (sine encoders, trigger rate max. 4 kHz). The channel of an axis is + its PositionFeedbackChannel, by default its axis index in the Npaq. Values are + published in BEC user coordinates of the A3200Motor with this axis_Id on the + same controller (its sign applied), i.e. in the same coordinates as the motor + readback; without such a motor the sign is 1. + drive_axis: any axis of the Npaq, used to address the drive (shared array). + trigger_input: DATAACQ trigger, 3 = rising TTL edge at UINT1 (gate start, default), + 0 = falling TTL edge (gate end); see DATAACQ_TRIGGER_UINT1_TTL_RISING. + """ + + USER_ACCESS = ["controller", "arm", "read_line", "calibrate", "disarm"] + + positions = Cpt( + AsyncMultiSignal, + name="positions", + signals=[f"enc{i}" for i in range(DATAACQ_POSITION_SOURCES)] + + [f"enc{i}_centre" for i in range(DATAACQ_POSITION_SOURCES)], + ndim=1, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + kind="normal", + doc="Positions latched at each trigger, in user units, one sub-signal per channel.", + ) + progress = Cpt(ProgressSignal, doc="Lines captured, for the scan progress bar") + + def __init__( + self, + prefix="", + *, + name, + host="localhost", + port=8000, + channels=None, + drive_axis="X", + trigger_input=DATAACQ_TRIGGER_UINT1_TTL_RISING, + socket_cls=SocketIO, + device_manager=None, + scan_info=None, + **kwargs, + ): + self.controller = A3200Controller( + socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager + ) + self.channels = {int(ch): str(axis) for ch, axis in (channels or {}).items()} + for ch in self.channels: + if not 0 <= ch < DATAACQ_POSITION_SOURCES: + raise ValueError( + f"DATAACQ source {ch} out of range 0..{DATAACQ_POSITION_SOURCES - 1}" + ) + self.drive_axis = drive_axis + self.trigger_input = trigger_input + self._offsets = {ch: 0.0 for ch in self.channels} + self._signs = None # channel -> sign of the BEC motor, resolved in calibrate() + self._n_triggers = 0 + self._correlation_group = "monitored" + self._lines_total = 0 + self._lines_read = 0 + super().__init__( + prefix, name=name, scan_info=scan_info, device_manager=device_manager, **kwargs + ) + if len(self.channels) < 2: + raise ValueError("Configure at least two encoder channels for position capture.") + # only the configured channels are published (strict signal validation) + self.positions.signal_info.signals = self.positions._unify_signals( + [f"enc{ch}{suffix}" for ch in sorted(self.channels) for suffix in ("", "_centre")] + ) + + def wait_for_connection(self, all_signals=False, timeout: float = 30.0) -> None: + self.controller.on(timeout=timeout) + + def destroy(self): + self.controller.off(update_config=False) + return super().destroy() + + def on_stage(self) -> None: + frames = getattr(getattr(self, "scan_info", None), "msg", None) + frames_per_trigger = getattr(frames, "frames_per_trigger", 1) if frames else 1 + self._correlation_group = "monitored" if frames_per_trigger == 1 else "burst_group" + # one readout per line; the scan's num_points is the number of lines + self._lines_total = int(getattr(frames, "num_points", 0) or 0) if frames else 0 + self._lines_read = 0 + if self._lines_total: + self.progress.put(value=0, max_value=self._lines_total, done=False) + + def calibrate(self) -> dict[str, float]: + """ + Determine the offset between encoder counts and program position of each channel: + one capture triggered by a software PSO fire with the axes at rest. + + Fires the PSO output once: the line trigger receiver (DDG) must not be armed. + + Returns the offsets by signal name (enc). String keys: the return value + of an RPC call is sent with msgpack, which rejects integer map keys, and the scan + would wait forever for the call to finish. + """ + channels = sorted(self.channels) + self.controller.dataacq_arm( + self.drive_axis, DATAACQ_TRIGGER_PSO_FIRING_EVENT, channels, n_triggers=1 + ) + self.controller.command(f"PSOCONTROL {self.drive_axis} RESET") + self.controller.command(f"PSOCONTROL {self.drive_axis} FIRE") + time.sleep(0.05) + raw = self.controller.read_drive_array(self.drive_axis, 0, len(channels)) + self.controller.dataacq_off(self.drive_axis) + for ch, counts in zip(channels, raw): + axis = self.channels[ch] + position = self.controller.get_position(axis) + self._offsets[ch] = position - counts / self.controller.counts_per_unit(axis) + self._signs = self._motor_signs() + return {f"enc{ch}": offset for ch, offset in self._offsets.items()} + + def _motor_signs(self) -> dict[int, int]: + """Sign of the BEC motor of each channel: A3200Motor with the same controller and axis.""" + motors = {} + devices = getattr(self.device_manager, "devices", None) or {} + for device in list(devices.values()): + obj = getattr(device, "obj", device) + if isinstance(obj, A3200Motor) and obj.controller is self.controller: + motors[obj.axis_Id] = obj + signs = {} + for ch, axis in self.channels.items(): + motor = motors.get(axis) + if motor is None: + logger.warning( + f"{self.name}: no A3200Motor for axis {axis} on {self.controller}; channel" + f" {ch} is published in controller coordinates (sign 1)." + ) + signs[ch] = motor.sign if motor is not None else 1 + return signs + + def arm(self, n_triggers: int) -> None: + """Arm the capture of the next n_triggers trigger edges (start of the array).""" + self._n_triggers = int(n_triggers) + self.controller.dataacq_arm( + self.drive_axis, self.trigger_input, sorted(self.channels), self._n_triggers + ) + + def disarm(self) -> None: + self.controller.dataacq_off(self.drive_axis) + + def read_line(self, centre_shift: float = 0.0) -> dict[str, list[float]]: + """ + Read the positions of the armed triggers and publish them, both as captured + (enc: position at the trigger edge, i.e. the start of the exposure) and + as exposure centres (enc_centre). centre_shift is the distance from the + start to the centre of an exposure (the scan passes v * gate_width / 2); it is + applied in the direction of motion to the channels that move during the line + (span of the line larger than 2 * centre_shift), the others are unchanged. + Returns the positions too. + """ + channels = sorted(self.channels) + if self._signs is None: + self._signs = self._motor_signs() + raw = self.controller.read_drive_array(self.drive_axis, 0, self._n_triggers * len(channels)) + # Npaq interleaves the inputs per trigger, in ascending channel order + data = np.asarray(raw, dtype=float).reshape(self._n_triggers, len(channels)) + timestamp = time.time() + result = {} + for i, ch in enumerate(channels): + cpu = self.controller.counts_per_unit(self.channels[ch]) + # controller coordinates -> user coordinates of the BEC motor + captured = self._signs[ch] * (data[:, i] / cpu + self._offsets[ch]) + span = captured[-1] - captured[0] if len(captured) > 1 else 0.0 + shift = np.sign(span) * centre_shift if abs(span) > 2 * abs(centre_shift) else 0.0 + result[f"enc{ch}"] = captured + result[f"enc{ch}_centre"] = captured + shift + self.positions.put( + { + f"{self.positions.name}_{key}": {"value": value, "timestamp": timestamp} + for key, value in result.items() + }, + correlation_group=self._correlation_group, + ) + self._lines_read += 1 + if self._lines_total: + self.progress.put( + value=self._lines_read, + max_value=self._lines_total, + done=self._lines_read >= self._lines_total, + ) + return {key: value.tolist() for key, value in result.items()} diff --git a/csaxs_bec/devices/panda_box/layouts/sastt_line_trigger.ini b/csaxs_bec/devices/panda_box/layouts/sastt_line_trigger.ini new file mode 100644 index 00000000..6e4bb072 --- /dev/null +++ b/csaxs_bec/devices/panda_box/layouts/sastt_line_trigger.ini @@ -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 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 =' ('' 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 diff --git a/csaxs_bec/devices/sim/sim_a3200.py b/csaxs_bec/devices/sim/sim_a3200.py new file mode 100644 index 00000000..c6ad6ab5 --- /dev/null +++ b/csaxs_bec/devices/sim/sim_a3200.py @@ -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": "%\\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 | RAMP RATE + 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 1 INPUT | LOAD | RANGE , | 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 1 TRIGGER | INPUT | ARRAY , | 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 READ $global[] [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() diff --git a/csaxs_bec/devices/smargon/README.md b/csaxs_bec/devices/smargon/README.md index 53a74a2b..ccac97e6 100644 --- a/csaxs_bec/devices/smargon/README.md +++ b/csaxs_bec/devices/smargon/README.md @@ -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 diff --git a/csaxs_bec/devices/smargon/controller.py b/csaxs_bec/devices/smargon/controller.py index d5a7ca88..e2c00605 100644 --- a/csaxs_bec/devices/smargon/controller.py +++ b/csaxs_bec/devices/smargon/controller.py @@ -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() diff --git a/csaxs_bec/devices/smargon/positioner.py b/csaxs_bec/devices/smargon/positioner.py index 032e1079..c48361ea 100644 --- a/csaxs_bec/devices/smargon/positioner.py +++ b/csaxs_bec/devices/smargon/positioner.py @@ -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 diff --git a/csaxs_bec/scans/__init__.py b/csaxs_bec/scans/__init__.py index 60909788..802a5c1d 100644 --- a/csaxs_bec/scans/__init__.py +++ b/csaxs_bec/scans/__init__.py @@ -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 diff --git a/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py b/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py new file mode 100644 index 00000000..6a8c089e --- /dev/null +++ b/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py @@ -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) and the exposure centres +(aerocapture_positions_enc_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. 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] diff --git a/docs/developer/aerotech_stages.md b/docs/developer/aerotech_stages.md new file mode 100644 index 00000000..b12d22d4 --- /dev/null +++ b/docs/developer/aerotech_stages.md @@ -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`: position at the start of each gate, +- `aerocapture_positions_enc_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. diff --git a/docs/user/saxs/aerotech_stages/aerotech_stages.md b/docs/user/saxs/aerotech_stages/aerotech_stages.md new file mode 100644 index 00000000..c6f8185b --- /dev/null +++ b/docs/user/saxs/aerotech_stages/aerotech_stages.md @@ -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. diff --git a/docs/user/saxs/aerotech_stages/run_up_deviation_1x1_10um_20ms.png b/docs/user/saxs/aerotech_stages/run_up_deviation_1x1_10um_20ms.png new file mode 100644 index 00000000..8e96747a Binary files /dev/null and b/docs/user/saxs/aerotech_stages/run_up_deviation_1x1_10um_20ms.png differ diff --git a/docs/user/saxs/aerotech_stages/run_up_deviation_2x2_100um_5ms.png b/docs/user/saxs/aerotech_stages/run_up_deviation_2x2_100um_5ms.png new file mode 100644 index 00000000..3e4e1607 Binary files /dev/null and b/docs/user/saxs/aerotech_stages/run_up_deviation_2x2_100um_5ms.png differ diff --git a/docs/user/saxs/aerotech_stages/run_up_deviation_2x2_40um_20ms.png b/docs/user/saxs/aerotech_stages/run_up_deviation_2x2_40um_20ms.png new file mode 100644 index 00000000..615c24d2 Binary files /dev/null and b/docs/user/saxs/aerotech_stages/run_up_deviation_2x2_40um_20ms.png differ diff --git a/docs/user/saxs/aerotech_stages/run_up_deviation_3p5x3_100um_5ms.png b/docs/user/saxs/aerotech_stages/run_up_deviation_3p5x3_100um_5ms.png new file mode 100644 index 00000000..09e7e5cb Binary files /dev/null and b/docs/user/saxs/aerotech_stages/run_up_deviation_3p5x3_100um_5ms.png differ diff --git a/docs/user/saxs/aerotech_stages/run_up_zoom_1x1_10um_20ms.png b/docs/user/saxs/aerotech_stages/run_up_zoom_1x1_10um_20ms.png new file mode 100644 index 00000000..e76c17ea Binary files /dev/null and b/docs/user/saxs/aerotech_stages/run_up_zoom_1x1_10um_20ms.png differ diff --git a/docs/user/saxs/aerotech_stages/run_up_zoom_2x2_100um_5ms.png b/docs/user/saxs/aerotech_stages/run_up_zoom_2x2_100um_5ms.png new file mode 100644 index 00000000..27f51bc9 Binary files /dev/null and b/docs/user/saxs/aerotech_stages/run_up_zoom_2x2_100um_5ms.png differ diff --git a/docs/user/saxs/aerotech_stages/run_up_zoom_2x2_40um_20ms.png b/docs/user/saxs/aerotech_stages/run_up_zoom_2x2_40um_20ms.png new file mode 100644 index 00000000..4662d513 Binary files /dev/null and b/docs/user/saxs/aerotech_stages/run_up_zoom_2x2_40um_20ms.png differ diff --git a/docs/user/saxs/saxs.md b/docs/user/saxs/saxs.md index 727d8d19..74eb45a7 100644 --- a/docs/user/saxs/saxs.md +++ b/docs/user/saxs/saxs.md @@ -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. + ## Reference documents diff --git a/tests/tests_bec_ipython_client/test_cSAXS_aerotech.py b/tests/tests_bec_ipython_client/test_cSAXS_aerotech.py new file mode 100644 index 00000000..49e8a053 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_cSAXS_aerotech.py @@ -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() diff --git a/tests/tests_devices/test_a3200.py b/tests/tests_devices/test_a3200.py new file mode 100644 index 00000000..eacabc7b --- /dev/null +++ b/tests/tests_devices/test_a3200.py @@ -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 diff --git a/tests/tests_devices/test_panda_line_trigger.py b/tests/tests_devices/test_panda_line_trigger.py new file mode 100644 index 00000000..6125a386 --- /dev/null +++ b/tests/tests_devices/test_panda_line_trigger.py @@ -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 diff --git a/tests/tests_devices/test_smargon_device.py b/tests/tests_devices/test_smargon_device.py index 8f10b5c5..d05f2fb5 100644 --- a/tests/tests_devices/test_smargon_device.py +++ b/tests/tests_devices/test_smargon_device.py @@ -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() diff --git a/tests/tests_scans/test_aerotech_cont_grid_scan.py b/tests/tests_scans/test_aerotech_cont_grid_scan.py new file mode 100644 index 00000000..6e59cdd0 --- /dev/null +++ b/tests/tests_scans/test_aerotech_cont_grid_scan.py @@ -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"] diff --git a/zensical.toml b/zensical.toml index 37fe217e..1d80efc8 100644 --- a/zensical.toml +++ b/zensical.toml @@ -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" }, ] }, ]