diff --git a/csaxs_bec/device_configs/sastt_aerotech.yaml b/csaxs_bec/device_configs/sastt_aerotech.yaml new file mode 100644 index 00000000..dc5b4f88 --- /dev/null +++ b/csaxs_bec/device_configs/sastt_aerotech.yaml @@ -0,0 +1,91 @@ +############################################################ +# SAXS-TT Aerotech stages: Npaq-B-40B-40B/ULTRA, A3200 software on a Windows PC +# (FireWire to the Npaq). BEC connects to the A3200 ASCII command interface on that PC. +# A3200 setup: CommandSetup = 0x00010004 (enabled + Always Send EOS), CommandPort = 8000. +# +# TODO commissioning: host name of the A3200 PC, A3200 axis names, velocities, limits. +# UU (ADRS-150 slipring follower) follows U on the controller and is not a BEC device. +# Limits [0, 0] = no BEC soft limits; the controller's SoftwareLimitLow/High still apply. +############################################################ + +aerox: + description: Aerotech X, ALS25020 linear stage + deviceClass: csaxs_bec.devices.aerotech.a3200.A3200Motor + deviceConfig: + axis_Id: X + axis_nr: 0 + host: a3200-saxstt.psi.ch + port: 8000 + velocity: 1.0 + egu: mm + limits: + - 0 + - 0 + 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: 1 + host: a3200-saxstt.psi.ch + port: 8000 + velocity: 0.5 + egu: mm + limits: + - 0 + - 0 + sign: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + deviceTags: + - sastt_aerotech +aeroz: + description: Aerotech Z, ATS20010 linear stage + deviceClass: csaxs_bec.devices.aerotech.a3200.A3200Motor + deviceConfig: + axis_Id: Z + axis_nr: 2 + host: a3200-saxstt.psi.ch + port: 8000 + velocity: 1.0 + egu: mm + limits: + - 0 + - 0 + sign: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + deviceTags: + - sastt_aerotech +aerorot: + description: Aerotech U, ABRT-200 rotation stage (UU follows on the controller) + deviceClass: csaxs_bec.devices.aerotech.a3200.A3200Motor + deviceConfig: + axis_Id: U + axis_nr: 3 + host: a3200-saxstt.psi.ch + port: 8000 + velocity: 10.0 + egu: deg + tolerance: 0.001 + limits: + - 0 + - 0 + sign: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + 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..b89ffc68 --- /dev/null +++ b/csaxs_bec/device_configs/simulated_omny/simulated_sastt_aerotech.yaml @@ -0,0 +1,86 @@ +############################################################ +# Simulated SAXS-TT Aerotech stages (sim_a3200), same devices as sastt_aerotech.yaml. +# 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 + host: sim-a3200-saxstt + port: 8000 + velocity: 1.0 + egu: mm + limits: + - 0 + - 0 + sign: 1 + 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: 1 + host: sim-a3200-saxstt + port: 8000 + velocity: 0.5 + egu: mm + limits: + - 0 + - 0 + sign: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + deviceTags: + - sastt_aerotech +aeroz: + description: Aerotech Z, ATS20010 linear stage + deviceClass: csaxs_bec.devices.sim.sim_a3200.SimA3200Motor + deviceConfig: + axis_Id: Z + axis_nr: 2 + host: sim-a3200-saxstt + port: 8000 + velocity: 1.0 + egu: mm + limits: + - 0 + - 0 + sign: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + deviceTags: + - sastt_aerotech +aerorot: + description: Aerotech U, ABRT-200 rotation stage (UU follows on the controller) + deviceClass: csaxs_bec.devices.sim.sim_a3200.SimA3200Motor + deviceConfig: + axis_Id: U + axis_nr: 3 + host: sim-a3200-saxstt + port: 8000 + velocity: 10.0 + egu: deg + tolerance: 0.001 + limits: + - 0 + - 0 + sign: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + 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..e1aa544d --- /dev/null +++ b/csaxs_bec/devices/aerotech/README.md @@ -0,0 +1,47 @@ +# 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). + +| BEC device | A3200 axis | Stage | Type | +|------------|------------|----------|------------------------------------------------| +| aerox | X | ALS25020 | linear | +| aeroy | Y | AVSI125 | vertical lift, 25 mm | +| aeroz | Z | ATS20010 | linear | +| aerorot | U | ABRT-200 | rotation | +| - | UU | ADRS-150 | slipring follower of U, handled on the controller | + +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. Set host, axis names, velocities and limits in `sastt_aerotech.yaml`. +7. Check `ProgramPositionFeedback` matches the position shown in Motion Composer. +8. Check how UU follows U (gantry, slave or program) before rotating. + +## Next: continuous line scan + +Fast axis moving continuously, slow axis stepping between lines, start of each line +triggered by position with PSO (position synchronized output) on the fast axis: +`PSOCONTROL`, `PSOWINDOW`/`PSODISTANCE`, `PSOOUTPUT` drive the digital output when the +armed position is crossed. To be implemented once the hardware is reachable. 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..be51d51d --- /dev/null +++ b/csaxs_bec/devices/aerotech/a3200.py @@ -0,0 +1,570 @@ +""" +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.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 + +# 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", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not hasattr(self, "_rx_buffer"): + self._rx_buffer = "" + + @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), + "cw_limit": bool(drive_status & DRIVESTATUS_CW_EOT_LIMIT), + "ccw_limit": bool(drive_status & DRIVESTATUS_CCW_EOT_LIMIT), + } + + @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 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 + 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. + """ + + USER_ACCESS = ["controller", "enable", "disable", "home", "fault_ack", "status"] + 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") + 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, + require_homed=True, + 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.require_homed = require_homed + 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.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.05) + while self.motor_is_moving.get(): + val = self.readback.read() + self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time()) + time.sleep(0.1) + 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 status(self) -> dict: + return self.controller.axis_status(self.axis_Id) diff --git a/csaxs_bec/devices/sim/sim_a3200.py b/csaxs_bec/devices/sim/sim_a3200.py new file mode 100644 index 00000000..40a0f802 --- /dev/null +++ b/csaxs_bec/devices/sim/sim_a3200.py @@ -0,0 +1,298 @@ +""" +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. +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 +""" + +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, + A3200Motor, +) +from csaxs_bec.devices.sim.sim_socket import SimSocketBase, SimStateRegistry + +logger = bec_logger.logger + +_STATUS_ITEM_RE = re.compile(r"\(([^)]*)\)") + + +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.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 drive_status(self) -> int: + return DRIVESTATUS_ENABLED if self.enabled else 0 + + +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() + + 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 + ): + ax = self.axis(name) + 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) + target = value if cmd == "MOVEABS" else ax.position() + value + 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 "" + logger.warning(f"[sim a3200] {self.host}:{self.port} unhandled command '{line}'") + return None + + @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) < 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 == "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, + device_manager=None, + sim_initial_position=None, + sim_enabled=True, + sim_homed=True, + sim_home_velocity=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, + 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, + ) diff --git a/tests/tests_devices/test_a3200.py b/tests/tests_devices/test_a3200.py new file mode 100644 index 00000000..eada5f51 --- /dev/null +++ b/tests/tests_devices/test_a3200.py @@ -0,0 +1,207 @@ +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, 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()