From c0f1b83d9fd229c3af0885500ab02b6dbc3c6216 Mon Sep 17 00:00:00 2001 From: menzel Date: Wed, 24 Jun 2026 15:57:31 +0200 Subject: [PATCH 1/6] feat(smargon): scaffold SmarGon goniometer device (DRAFT, untested vs hardware) Initial BEC ophyd integration for the SmarGon goniometer via the smargopolo RESTful API. Controls the virtual SCS axes (the server runs the kinematics; the underlying q1..q6 stages are never commanded directly). Structure mirrors the Canon CR-N300 device: an ophyd-free transport (real RestTransport over urllib + in-memory FakeTransport) with threaded poll-to-tolerance positioners and a PSIDeviceBase parent. Scope / decisions: - v1 movable axes: SHX SHY SHZ CHI PHI; OMEGA optional via has_omega (YAML). - Referencing is a deliberate operator action (reference()); moves refuse unless Mode.READY. - Soft limits are user-set; smargopolo owns the coupled hardware limits. Both fault paths handled: up-front PUT rejection fails the move immediately, and a mid-move fault (mode -> 99 ERROR) aborts the in-flight move with the rosout detail (SmarGon._raise_if_error). - Move completion = readback within (user-set) tolerance, done on first in-tolerance sample (robust to active position-hold dither). Status: DRAFT. 27 unit tests pass against FakeTransport, but NOTHING has been tested against a real smargopolo server or hardware. Do not commission as-is. Open questions (to confirm with the smargopolo maintainer, W. Glettig): - Stop semantics: no explicit stop endpoint; we abort by retargeting an axis to its current readback ("Follow Target" halt). Confirm this is the intended way. - Mid-move fault signalling: we rely on /mode -> 99 (ERROR) with the reason in /readbackMCS rosout.msg. Confirm an out-of-range / coupled-limit violation reliably drives this, so the abort path is dependable. - Per-poll cost: the mid-move check adds a /readbackMCS GET alongside the /readbackSCS position read (~20 req/s per moving axis at 0.1s). Revisit once we know whether /readbackSCS also carries mode, or once the per-motor `state` strings can give a truer moving/settled flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- csaxs_bec/device_configs/smargon.yaml | 61 +++ csaxs_bec/devices/smargon/README.md | 103 +++++ csaxs_bec/devices/smargon/__init__.py | 32 ++ csaxs_bec/devices/smargon/positioner.py | 227 ++++++++++ csaxs_bec/devices/smargon/smargon.py | 397 ++++++++++++++++++ .../devices/smargon/transport/__init__.py | 47 +++ csaxs_bec/devices/smargon/transport/base.py | 217 ++++++++++ csaxs_bec/devices/smargon/transport/errors.py | 37 ++ csaxs_bec/devices/smargon/transport/fake.py | 225 ++++++++++ csaxs_bec/devices/smargon/transport/rest.py | 212 ++++++++++ tests/tests_devices/test_smargon_device.py | 162 +++++++ tests/tests_devices/test_smargon_transport.py | 82 ++++ 12 files changed, 1802 insertions(+) create mode 100644 csaxs_bec/device_configs/smargon.yaml create mode 100644 csaxs_bec/devices/smargon/README.md create mode 100644 csaxs_bec/devices/smargon/__init__.py create mode 100644 csaxs_bec/devices/smargon/positioner.py create mode 100644 csaxs_bec/devices/smargon/smargon.py create mode 100644 csaxs_bec/devices/smargon/transport/__init__.py create mode 100644 csaxs_bec/devices/smargon/transport/base.py create mode 100644 csaxs_bec/devices/smargon/transport/errors.py create mode 100644 csaxs_bec/devices/smargon/transport/fake.py create mode 100644 csaxs_bec/devices/smargon/transport/rest.py create mode 100644 tests/tests_devices/test_smargon_device.py create mode 100644 tests/tests_devices/test_smargon_transport.py diff --git a/csaxs_bec/device_configs/smargon.yaml b/csaxs_bec/device_configs/smargon.yaml new file mode 100644 index 00000000..b0d23421 --- /dev/null +++ b/csaxs_bec/device_configs/smargon.yaml @@ -0,0 +1,61 @@ +# Example BEC device-config entries for the SmarGon goniometer (via smargopolo). +# +# The device controls the virtual SCS axes; smargopolo runs the kinematics and +# drives the underlying q1..q6 stages. Referencing is a deliberate operator action: +# after loading, call `smargon.reference()` once smargopolo is up. + +# --- Real goniometer (smargopolo REST API) --------------------------------------- +smargon: + description: SmarGon goniometer (SCS axes via smargopolo REST API) + deviceClass: csaxs_bec.devices.smargon.smargon.SmarGon + deviceConfig: + host: smargopolo # smargopolo server hostname + port: 3000 # REST API port + timeout: 5.0 # per-request transport timeout [s] + has_omega: false # set true on builds with the OMEGA rotation drive + # Per-move completion tolerance: a float (all axes) or per-axis mapping. + tolerance: + SHX: 0.001 # mm + SHY: 0.001 + SHZ: 0.001 + CHI: 0.01 # deg + PHI: 0.01 + # Soft limits in SCS units [low, high]. These are typically USER-set (per + # experiment), not hardware ranges: the true per-axis travel of a SmarGon is + # coupled across all SCS axes and cannot be reliably pre-declared. smargopolo + # owns the real hardware limits; if a target violates them the move fails (the + # PUT is rejected, or -- if the fault only shows after partial motion -- the + # device aborts the move on the controller's ERROR mode). Omit an axis here to + # leave it unbounded in BEC and rely on the controller. + limits: + SHX: [-2.5, 2.5] + SHY: [-2.5, 2.5] + SHZ: [-2.5, 2.5] + poll_interval: 0.1 # readback polling cadence while moving [s] + move_timeout: 60.0 # per-move timeout [s] + deviceTags: + - goniometer + - smargon + enabled: true + onFailure: retry + readOnly: false + readoutPriority: baseline + softwareTrigger: false + +# --- Simulation / offline (no server; uses FakeTransport) ------------------------ +smargon_sim: + description: SmarGon goniometer (simulation, no smargopolo server required) + deviceClass: csaxs_bec.devices.smargon.smargon.SmarGon + deviceConfig: + simulation: true + has_omega: true # exercise OMEGA in simulation + gradual_motion: true # simulate finite-speed travel (exercise move status) + deviceTags: + - goniometer + - smargon + - simulation + enabled: true + onFailure: retry + readOnly: false + readoutPriority: baseline + softwareTrigger: false diff --git a/csaxs_bec/devices/smargon/README.md b/csaxs_bec/devices/smargon/README.md new file mode 100644 index 00000000..53e02a46 --- /dev/null +++ b/csaxs_bec/devices/smargon/README.md @@ -0,0 +1,103 @@ +# SmarGon goniometer (BEC integration via smargopolo) + +BEC ophyd device for the [SmarGon](https://www.smaract.com/en/smargon) goniometer. +It controls the **virtual SmarGon Coordinate System (SCS) axes** through the +[smargopolo](http://smargopolo:3000) RESTful API. smargopolo runs the kinematics and +drives the underlying `q1..q6` SmarAct MCS2 stages; this device never commands those +directly (that is the separate low-level `csaxs_bec.devices.smaract` device). + +## Layout + +``` +smargon/ + transport/ # ophyd-free control plane (unit-testable, no BEC needed) + base.py # SmargopoloTransport ABC + Axis/Mode enums + AXIS_INFO + rest.py # RestTransport -- real urllib client against :3000 + fake.py # FakeTransport -- in-memory sim (offline mode + tests) + errors.py # typed exceptions + positioner.py # SmargonAxis(Device, PositionerBase) -- one SCS axis + smargon.py # SmarGon(PSIDeviceBase) -- parent: transport, axes, mode +``` + +The transport mirrors the Canon CR-N300 device's structure (HTTP transport + an +in-memory fake + threaded polling positioners), since both wrap a non-EPICS, +poll-to-confirm HTTP backend. + +## Axes (v1) + +Movable positioners: `shx shy shz` (mm) and `chi phi` (deg). `omega` (deg) is +**optional hardware** — enable it per deployment with `has_omega: true`; otherwise +it is `Kind.omitted` and refuses to move. The O-vector (`OX/OY/OZ`) and beamline +nudges (`BX/BY/BZ`) are reachable through the transport but not exposed as v1 +positioners; `nudge_beamline()` provides the BCS nudge path. + +Raw `q1..q6` motor positions are read-only diagnostics via `motor_positions()`. + +## Referencing (read this before moving) + +smargopolo only follows targets in `Mode.READY`. **Referencing physically moves the +goniometer**, so it is a deliberate operator action — never automatic: + +```python +smargon.get_mode() # 'UNINITIALIZED' on a fresh server +smargon.reference() # PUT /mode?mode=1, waits until READY (moves hardware!) +smargon.is_ready() # True +smargon.shx.move(0.5) # now honoured +``` + +Any axis move while not READY raises `SmargopoloModeError` with a "reference first" +message rather than silently doing nothing. `mode` and `rosout` signals surface the +state machine and last ROS message (error detail) for diagnostics. + +## Usage + +```python +smargon.shx.move(0.5) # single-axis move (mm) +smargon.move_scs(SHX=0.1, SHZ=18) # coordinated move in one /targetSCS call +smargon.nudge_beamline(BX=0.01) # beamline-frame nudge (/nudgeBCS) +smargon.stop_all() # halt all enabled axes +smargon.transport_info() # connection / identity diagnostics +``` + +## Configuration + +See `csaxs_bec/device_configs/smargon.yaml` for real + simulation entries. +Key `deviceConfig` keys: `host`, `port`, `has_omega`, `tolerance` (float or per-axis +map), `limits` (per-axis `[low, high]` soft limits — **verify against the build**), +`poll_interval`, `move_timeout`. Simulation: `simulation: true` (+ optional +`gradual_motion: true`) uses `FakeTransport`, no server required. + +## Tests + +```sh +pytest tests/tests_devices/test_smargon_transport.py tests/tests_devices/test_smargon_device.py +``` + +All run against `FakeTransport` — no hardware or server needed. + +## Limits & coupled-axis faults + +SCS soft limits are **user-set** (per experiment), not hardware ranges: a SmarGon's +true per-axis travel is coupled across all SCS axes (like a hexapod) and can't be +reliably declared in advance. smargopolo owns the real limits. The device handles +both failure modes: + +- **Rejected up front** — if smargopolo refuses an out-of-range target, the PUT + returns an error and the move status fails immediately. +- **Faulted mid-move** — if a target is accepted (`202`) but the controller goes to + `Mode.ERROR` only after partial motion, the move waiters detect it on the next + poll and fail the status with the controller's `rosout` detail (rather than + running to the move timeout). See `SmarGon._raise_if_error`. + +Move completion is by **readback tolerance** (user-set; done on the first in-tolerance +sample, so active position-hold dithering doesn't block completion). + +## Open items / to confirm with the smargopolo maintainer (Wayne Glettig) + +- **Stop semantics**: no explicit stop endpoint; we retarget axes to their readback + ("Follow Target" halt). Confirm this is the intended way to abort a move. +- **Mid-move fault signal**: we infer faults from `Mode.ERROR`. Confirm an + out-of-range / coupled-limit violation reliably drives `/mode` to 99 (and that + `rosout` carries the reason), so the abort path is dependable. +- *(resolved)* OMEGA is drivable via `/targetSCS` wherever it physically exists — + handled by `has_omega`. diff --git a/csaxs_bec/devices/smargon/__init__.py b/csaxs_bec/devices/smargon/__init__.py new file mode 100644 index 00000000..e869ba65 --- /dev/null +++ b/csaxs_bec/devices/smargon/__init__.py @@ -0,0 +1,32 @@ +"""SmarGon goniometer integration for BEC (via the smargopolo REST API). + +Public entry point is :class:`SmarGon`. The control transport +(:mod:`.transport`) is ophyd-free and independently importable, so the REST client +and its in-memory fake can be used and tested without the BEC stack. +""" + +from __future__ import annotations + +from .positioner import SmargonAxis +from .smargon import SmarGon +from .transport import ( + Axis, + BeamlineAxis, + FakeTransport, + Mode, + RestTransport, + SmargopoloInfo, + SmargopoloTransport, +) + +__all__ = [ + "SmarGon", + "SmargonAxis", + "Axis", + "BeamlineAxis", + "Mode", + "SmargopoloTransport", + "SmargopoloInfo", + "FakeTransport", + "RestTransport", +] diff --git a/csaxs_bec/devices/smargon/positioner.py b/csaxs_bec/devices/smargon/positioner.py new file mode 100644 index 00000000..18a03279 --- /dev/null +++ b/csaxs_bec/devices/smargon/positioner.py @@ -0,0 +1,227 @@ +"""Transport-backed positioner for a single SmarGon SCS axis. + +``SmargonAxis`` is a real ophyd positioner (``Device`` + ``PositionerBase``) so BEC +treats each SCS axis (SHX/SHY/SHZ/CHI/PHI/OMEGA) as a motor: ``move()``/``set()`` +return a ``DeviceStatus`` that completes when the axis reaches target (or on +timeout/stop), plus ``stop()``. It follows the threaded-move pattern of the Canon +``CanonAxis`` but reads/writes through the parent device's injected +:class:`~csaxs_bec.devices.smargon.transport.base.SmargopoloTransport`. + +Because smargopolo runs the kinematics, each axis simply PUTs its own SCS target +and polls its own SCS readback; the coupling to the q1..q6 motors is the server's +job, not ours. The axis never opens its own connection: it borrows the parent's +transport and a shared lock so concurrent axis commands serialise onto one session. +""" + +from __future__ import annotations + +import threading +import time +from typing import Callable + +from ophyd import Component as Cpt +from ophyd import Device, DeviceStatus, Kind, PositionerBase, Signal +from ophyd.utils import LimitError + +from .transport.base import Axis, SmargopoloTransport + + +class SmargonAxis(Device, PositionerBase): + """One SCS axis of the SmarGon goniometer. + + Parameters + ---------- + axis: + Which :class:`Axis` this positioner drives. + egu: + Engineering units for the readback (``"mm"`` or ``"deg"``). + limits: + Optional soft ``(low, high)`` limits. ``None`` (default) means unbounded + -- the SCS travel ranges are build-specific and should be set in config. + tolerance: + Absolute readback tolerance for declaring a move complete. + poll_interval: + Seconds between readback polls while moving. + move_timeout: + Seconds before a move fails with ``TimeoutError``. + """ + + readback = Cpt(Signal, value=0.0, kind=Kind.hinted) + setpoint = Cpt(Signal, value=0.0, kind=Kind.normal) + motor_is_moving = Cpt(Signal, value=0, kind=Kind.normal) + + SUB_READBACK = "readback" + _default_sub = SUB_READBACK + + def __init__( + self, + prefix: str = "", + *, + name: str, + axis: Axis, + egu: str = "", + limits: tuple[float, float] | None = None, + tolerance: float = 0.001, + poll_interval: float = 0.1, + move_timeout: float = 60.0, + parent=None, + kind=None, + **kwargs, + ) -> None: + self._axis = Axis(axis) + self._egu = egu + self._tolerance = tolerance + self._poll_interval = poll_interval + self._move_timeout = move_timeout + self._limits = tuple(limits) if limits is not None else None + self._stopped = False + self._move_thread: threading.Thread | None = None + self._fallback_lock = threading.RLock() + # Optional gate set by the parent (e.g. refuse to move unless READY). + self.pre_move_hook: Callable[[float], None] | None = None + + super().__init__(prefix, name=name, parent=parent, kind=kind, **kwargs) + + # -- transport access -------------------------------------------------- + + @property + def _transport(self) -> SmargopoloTransport: + transport = getattr(self.parent, "transport", None) + if transport is None: + raise RuntimeError(f"{self.name}: parent has no transport") + return transport + + @property + def _lock(self) -> threading.RLock: + return getattr(self.parent, "_transport_lock", None) or self._fallback_lock + + def _call(self, fn): + """Run a transport call under the shared lock.""" + with self._lock: + return fn() + + # -- positioner interface ---------------------------------------------- + + @property + def limits(self) -> tuple[float, float]: + return self._limits if self._limits is not None else (0.0, 0.0) + + @property + def low_limit(self) -> float: + return self.limits[0] + + @property + def high_limit(self) -> float: + return self.limits[1] + + @property + def egu(self) -> str: + return self._egu + + @property + def position(self) -> float: + return self.readback.get() + + def check_value(self, value: float) -> None: + if self._limits is None: + return + low, high = self._limits + if low != high and not low <= value <= high: + raise LimitError(f"{self.name}: position {value} outside limits {self._limits}") + + def _update_readback(self, value: float) -> None: + old = self.readback.get() + self.readback.put(value) + self._set_position(value) + self._run_subs( + sub_type=self.SUB_READBACK, old_value=old, value=value, timestamp=time.time() + ) + + def _check_controller_error(self) -> None: + """Raise if the parent reports smargopolo went to ERROR mid-move. + + Coupled hardware limits (the underlying stages' true ranges depend on all + SCS axes and are not reliably known to BEC) mean a target accepted with a + ``202`` can still fault *after* partial motion -- like a hexapod hitting a + joint limit. The parent installs this so the move fails loudly with the + controller's ``rosout`` detail rather than silently timing out. + """ + check = getattr(self.parent, "_raise_if_error", None) + if check is not None: + self._call(check) + + def _poll_until_target(self, target: float, status: DeviceStatus) -> None: + deadline = time.monotonic() + self._move_timeout + try: + while not self._stopped: + pos = self._call(lambda: self._transport.get_position(self._axis)) + self._update_readback(pos) + if abs(pos - target) <= self._tolerance: + break + self._check_controller_error() + if time.monotonic() > deadline: + raise TimeoutError( + f"{self.name}: move to {target} timed out after {self._move_timeout}s" + ) + time.sleep(self._poll_interval) + pos = self._call(lambda: self._transport.get_position(self._axis)) + self._update_readback(pos) + except Exception as exc: # pylint: disable=broad-except + self.motor_is_moving.put(0) + if not status.done: + status.set_exception(exc) + return + self.motor_is_moving.put(0) + if self._stopped: + if not status.done: + status.set_exception(RuntimeError(f"{self.name}: move interrupted by stop()")) + return + if not status.done: + status.set_finished() + + def move(self, position: float, wait: bool = True, timeout=None, moved_cb=None) -> DeviceStatus: + """Move the axis to *position*; return a status that completes at target.""" + self.check_value(position) + if self.pre_move_hook is not None: + self.pre_move_hook(position) + self._stopped = False + self.setpoint.put(position) + + status = DeviceStatus(self, timeout=timeout or self._move_timeout) + if moved_cb is not None: + status.add_callback(moved_cb) + self.motor_is_moving.put(1) + + try: + self._call(lambda: self._transport.move_absolute({self._axis: position})) + except Exception as exc: # pylint: disable=broad-except + self.motor_is_moving.put(0) + status.set_exception(exc) + return status + + self._move_thread = threading.Thread( + target=self._poll_until_target, args=(position, status), daemon=True + ) + self._move_thread.start() + + if wait: + status.wait() + return status + + def set(self, position: float, **kwargs) -> DeviceStatus: + """Alias for :meth:`move` with ``wait=False`` (BEC positioner interface).""" + return self.move(position, wait=False, **kwargs) + + def stop(self, *, success: bool = False) -> None: + """Halt this axis immediately (best-effort: never raises during teardown).""" + self._stopped = True + try: + self._call(lambda: self._transport.stop([self._axis])) + except Exception as exc: # pylint: disable=broad-except + self.log.warning("%s: transport stop failed: %s", self.name, exc) + finally: + if self._move_thread is not None: + self._move_thread.join(timeout=2.0) + self._move_thread = None + self.motor_is_moving.put(0) + super().stop(success=success) diff --git a/csaxs_bec/devices/smargon/smargon.py b/csaxs_bec/devices/smargon/smargon.py new file mode 100644 index 00000000..b5d2ee77 --- /dev/null +++ b/csaxs_bec/devices/smargon/smargon.py @@ -0,0 +1,397 @@ +"""SmarGon goniometer -- BEC ophyd device (via the smargopolo REST API). + +This device controls the *virtual* SmarGon Coordinate System (SCS) axes. The +smargopolo server runs the kinematics and drives the underlying q1..q6 stages; we +never command those directly (that is the separate low-level ``smaract`` device). + +Architecture +------------ +* ``self.transport`` -- a :class:`SmargopoloTransport` (real :class:`RestTransport` + or, when ``simulation: true``, :class:`FakeTransport`). All control goes through + it, serialised behind ``self._transport_lock``. +* ``shx/shy/shz/chi/phi`` (+ optional ``omega``) -- :class:`SmargonAxis` + positioners sharing the transport; they look like motors to BEC. +* ``mode`` / ``rosout`` -- read-only signals proxying the state machine + last ROS + message for diagnostics. + +State machine / referencing +--------------------------- +smargopolo only follows targets in ``Mode.READY``. Referencing physically moves +the goniometer, so it is a **deliberate operator action** here -- call +:meth:`reference`. Axes refuse to move unless READY (a pre-move guard surfaces a +clear "reference first" error instead of silently doing nothing). +""" + +from __future__ import annotations + +import threading +import time +from typing import TYPE_CHECKING + +from ophyd import Component as Cpt +from ophyd import Kind, Signal +from ophyd.utils import ReadOnlyError +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase + +from .positioner import SmargonAxis +from .transport.base import Axis, BeamlineAxis, Mode, SmargopoloTransport +from .transport.errors import SmargopoloModeError +from .transport.fake import FakeTransport +from .transport.rest import RestTransport + +if TYPE_CHECKING: # pragma: no cover + from bec_lib.devicemanager import ScanInfo + +try: # pragma: no cover + from bec_lib.logger import bec_logger + + logger = bec_logger.logger +except Exception: # pragma: no cover + import logging + + logger = logging.getLogger(__name__) + + +# Axes exposed as movable positioners in v1. OMEGA is appended at runtime when the +# deployment has the rotation drive (``has_omega: true``). +_V1_AXES: tuple[Axis, ...] = (Axis.SHX, Axis.SHY, Axis.SHZ, Axis.CHI, Axis.PHI) + + +class SmargopoloModeSignal(Signal): + """Read-only signal exposing the smargopolo state-machine mode (as an int).""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._metadata.update(connected=True, write_access=False) + + # pylint: disable=arguments-differ + def get(self, **kwargs): + value = int(self.parent._get_mode()) # noqa: SLF001 + self._readback = value + return value + + def put(self, value, **kwargs): + raise ReadOnlyError(f"{self.name} is read-only; use reference() to change mode") + + @property + def timestamp(self): + return time.time() + + +class SmargopoloRosoutSignal(Signal): + """Read-only signal exposing the last ROS message (carries error detail).""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._metadata.update(connected=True, write_access=False) + + # pylint: disable=arguments-differ + def get(self, **kwargs): + value = self.parent._get_info().rosout # noqa: SLF001 + self._readback = value + return value + + def put(self, value, **kwargs): + raise ReadOnlyError(f"{self.name} is read-only") + + @property + def timestamp(self): + return time.time() + + +class SmarGon(PSIDeviceBase): + """BEC ophyd device for the SmarGon goniometer driven through smargopolo. + + See the module docstring for architecture and the referencing contract. + """ + + USER_ACCESS = [ + "reference", + "is_ready", + "wait_until_ready", + "get_mode", + "stop_all", + "motor_positions", + "move_scs", + "nudge_beamline", + "transport_info", + ] + + # -- motion axes (look like motors to BEC) ----------------------------- + shx = Cpt(SmargonAxis, axis=Axis.SHX, egu="mm", kind=Kind.normal) + shy = Cpt(SmargonAxis, axis=Axis.SHY, egu="mm", kind=Kind.normal) + shz = Cpt(SmargonAxis, axis=Axis.SHZ, egu="mm", kind=Kind.normal) + chi = Cpt(SmargonAxis, axis=Axis.CHI, egu="deg", kind=Kind.normal) + phi = Cpt(SmargonAxis, axis=Axis.PHI, egu="deg", kind=Kind.normal) + # Optional rotation drive -- omitted unless ``has_omega`` is set (see __init__). + omega = Cpt(SmargonAxis, axis=Axis.OMEGA, egu="deg", kind=Kind.normal) + + # -- diagnostics ------------------------------------------------------- + mode = Cpt(SmargopoloModeSignal, value=int(Mode.UNINITIALIZED), kind=Kind.normal) + rosout = Cpt(SmargopoloRosoutSignal, value="", kind=Kind.config) + + def __init__( + self, + *, + name: str, + host: str = "smargopolo", + port: int = 3000, + simulation: bool = False, + timeout: float = 5.0, + has_omega: bool = False, + limits: dict | None = None, + tolerance: float | dict | None = None, + poll_interval: float = 0.1, + move_timeout: float = 60.0, + gradual_motion: bool = False, + scan_info: "ScanInfo | None" = None, + device_manager=None, + **kwargs, + ) -> None: + """See class/module docstring for the architecture. + + Args: + host/port: smargopolo server address (ignored when ``simulation``). + simulation: use :class:`FakeTransport` -- no server needed. + timeout: per-request transport timeout in seconds. + has_omega: enable the OMEGA rotation axis (build-dependent). + limits: optional ``{axis_name: [low, high]}`` soft limits (SCS units). + tolerance: per-move completion tolerance; a float (all axes) or a + ``{axis_name: value}`` mapping. Defaults to 1e-3. + poll_interval/move_timeout: readback polling cadence / move timeout. + gradual_motion: simulate finite-speed travel in the fake transport. + """ + self._simulation = simulation + self._has_omega = has_omega + self._transport_lock = threading.RLock() + + # Build the transport BEFORE super().__init__ so axis components can reach it. + if simulation: + self.transport: SmargopoloTransport = FakeTransport(gradual_motion=gradual_motion) + else: + self.transport = RestTransport(host=host, port=port, timeout=timeout) + + super().__init__(name=name, scan_info=scan_info, device_manager=device_manager, **kwargs) + + # Configure the enabled axes; disable OMEGA unless the build has it. + enabled = set(_V1_AXES) | ({Axis.OMEGA} if has_omega else set()) + limits = limits or {} + for ax in self._all_axes(): + if ax._axis not in enabled: # noqa: SLF001 + ax.kind = Kind.omitted + ax.pre_move_hook = self._make_disabled_hook(ax._axis) # noqa: SLF001 + continue + ax.pre_move_hook = self._ready_guard + ax._poll_interval = poll_interval # noqa: SLF001 + ax._move_timeout = move_timeout # noqa: SLF001 + lim = limits.get(ax._axis.value) # noqa: SLF001 + if lim is not None: + ax._limits = (float(lim[0]), float(lim[1])) # noqa: SLF001 + tol = self._axis_tolerance(tolerance, ax._axis) # noqa: SLF001 + if tol is not None: + ax._tolerance = float(tol) # noqa: SLF001 + + # -- axis helpers ------------------------------------------------------ + + def _all_axes(self) -> tuple[SmargonAxis, ...]: + return (self.shx, self.shy, self.shz, self.chi, self.phi, self.omega) + + def _enabled_axes(self) -> tuple[SmargonAxis, ...]: + return tuple(ax for ax in self._all_axes() if ax.kind != Kind.omitted) + + @staticmethod + def _axis_tolerance(tolerance, axis: Axis): + if tolerance is None: + return None + if isinstance(tolerance, dict): + return tolerance.get(axis.value) + return tolerance + + @staticmethod + def _make_disabled_hook(axis: Axis): + def _hook(_position: float) -> None: + raise SmargopoloModeError( + f"axis {axis.value} is not enabled on this SmarGon " + "(set has_omega: true in the device config to enable OMEGA)" + ) + + return _hook + + def _ready_guard(self, _position: float) -> None: + mode = self._get_mode() + if mode is not Mode.READY: + raise SmargopoloModeError( + f"{self.name} is in {mode.name} (mode {int(mode)}); call reference() and wait " + "for READY before moving" + ) + + # -- transport routing ------------------------------------------------- + + def _get_mode(self) -> Mode: + with self._transport_lock: + return self.transport.get_mode() + + def _get_info(self): + with self._transport_lock: + return self.transport.get_info() + + def _raise_if_error(self) -> None: + """Raise if smargopolo is in ERROR (used to abort in-flight moves). + + smargopolo accepts a target with ``202`` even when the coupled hardware + limits will be violated, then faults once motion hits the real boundary. + Move waiters poll this so a mid-move fault surfaces the controller's + ``rosout`` message instead of running until the move timeout. + """ + info = self._get_info() + if info.mode is Mode.ERROR: + raise SmargopoloModeError( + f"{self.name}: smargopolo entered ERROR during motion " + f"(likely a hardware/coupled-axis limit): {info.rosout or 'no detail'}" + ) + + # -- connection lifecycle ---------------------------------------------- + + # pylint: disable=arguments-differ + def wait_for_connection(self, all_signals: bool = False, timeout: float = 10.0) -> None: + """Open the transport session, then wait for signals.""" + with self._transport_lock: + self.transport.connect(timeout=timeout) + super().wait_for_connection(all_signals=all_signals, timeout=timeout) + + def on_connected(self) -> None: + """Log identity and seed enabled-axis readbacks.""" + try: + info = self._get_info() + logger.info( + f"{self.name} connected to smargopolo (S/N {info.serial_number!r}, " + f"mode {info.mode.name})" + ) + if info.mode is not Mode.READY: + logger.warning( + f"{self.name}: smargopolo is {info.mode.name}; call reference() before moving" + ) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"{self.name}: could not read smargopolo info: {exc}") + for ax in self._enabled_axes(): + try: + with self._transport_lock: + ax._update_readback(self.transport.get_position(ax._axis)) # noqa: SLF001 + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"{self.name}: initial readback for {ax.name} failed: {exc}") + + def on_stop(self) -> None: + """Halt all enabled axes.""" + self.stop_all() + + def on_destroy(self) -> None: + """Close the transport session.""" + try: + self.transport.close() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"{self.name}: transport close failed: {exc}") + + # -- control convenience (USER_ACCESS) --------------------------------- + + def reference(self, wait: bool = True, timeout: float = 120.0) -> Mode: + """Reference the goniometer (``Mode.REFERENCING`` -> ``READY``). + + WARNING: this physically moves the goniometer to find its reference marks. + It is a deliberate operator action. Returns the resulting mode. + """ + with self._transport_lock: + self.transport.set_mode(Mode.REFERENCING) + logger.info(f"{self.name}: referencing started") + if wait: + mode = self.wait_until_ready(timeout=timeout) + logger.info(f"{self.name}: referencing finished -> {mode.name}") + return mode + return self._get_mode() + + def is_ready(self) -> bool: + """Whether smargopolo is in ``Mode.READY`` (follow-target).""" + return self._get_mode() is Mode.READY + + def wait_until_ready(self, timeout: float = 120.0, poll: float = 0.5) -> Mode: + """Block until the mode is READY (or ERROR), or *timeout* elapses.""" + deadline = time.monotonic() + timeout + while True: + mode = self._get_mode() + if mode is Mode.READY: + return mode + if mode is Mode.ERROR: + info = self._get_info() + raise SmargopoloModeError(f"{self.name}: smargopolo in ERROR: {info.rosout}") + if time.monotonic() > deadline: + raise TimeoutError(f"{self.name}: not READY after {timeout}s (mode {mode.name})") + time.sleep(poll) + + def get_mode(self) -> str: + """Return the current state-machine mode name (for the user console).""" + return self._get_mode().name + + def stop_all(self) -> None: + """Halt every enabled axis immediately.""" + for ax in self._enabled_axes(): + ax.stop() + + def move_scs(self, wait: bool = True, **targets: float) -> None: + """Move several SCS axes at once in a single ``/targetSCS`` call. + + More efficient than moving axes one-by-one for coordinated moves, e.g. + ``smargon.move_scs(SHX=0.1, SHZ=18)``. Keys are SCS axis names. + """ + self._ready_guard(0.0) + parsed = {Axis(k): float(v) for k, v in targets.items()} + for ax in parsed: + self._axis_for(ax).check_value(parsed[ax]) + with self._transport_lock: + self.transport.move_absolute(parsed) + if wait: + for ax_enum, val in parsed.items(): + self._wait_axis(self._axis_for(ax_enum), val) + + def _wait_axis(self, axis: SmargonAxis, target: float, timeout: float | None = None) -> None: + deadline = time.monotonic() + (timeout or axis._move_timeout) # noqa: SLF001 + while True: + with self._transport_lock: + pos = self.transport.get_position(axis._axis) # noqa: SLF001 + axis._update_readback(pos) # noqa: SLF001 + if abs(pos - target) <= axis._tolerance: # noqa: SLF001 + return + self._raise_if_error() + if time.monotonic() > deadline: + raise TimeoutError(f"{axis.name}: move to {target} timed out") + time.sleep(axis._poll_interval) # noqa: SLF001 + + def _axis_for(self, axis: Axis) -> SmargonAxis: + for ax in self._all_axes(): + if ax._axis is axis: # noqa: SLF001 + return ax + raise KeyError(axis) + + def nudge_beamline(self, modify_offset: bool = False, **deltas: float) -> None: + """Nudge the sample along beamline axes (``BX``/``BY``/``BZ``). + + With ``modify_offset=True`` the O-vector is nudged (``/nudgeOBCS``). + """ + self._ready_guard(0.0) + parsed = {BeamlineAxis(k): float(v) for k, v in deltas.items()} + with self._transport_lock: + self.transport.nudge_beamline(parsed, modify_offset=modify_offset) + + def motor_positions(self) -> dict: + """Return the raw ``q1..q6`` motor positions (read-only diagnostics).""" + return self._get_info().motor_positions + + def transport_info(self) -> dict: + """Return connection/identity info for diagnostics.""" + info = self._get_info() if self.transport.connected else None + return { + "connected": self.transport.connected, + "simulation": self._simulation, + "has_omega": self._has_omega, + "mode": info.mode.name if info else None, + "serial_number": getattr(info, "serial_number", None), + "seq": getattr(info, "seq", None), + } diff --git a/csaxs_bec/devices/smargon/transport/__init__.py b/csaxs_bec/devices/smargon/transport/__init__.py new file mode 100644 index 00000000..1dd3cf7e --- /dev/null +++ b/csaxs_bec/devices/smargon/transport/__init__.py @@ -0,0 +1,47 @@ +"""Control-plane transport for the SmarGon goniometer (via smargopolo REST API). + +Pure standard-library; imports neither ophyd nor BEC, so the transport is unit +testable without hardware and standalone control works with the BEC stack absent. +""" + +from __future__ import annotations + +from .base import ( + AXIS_INFO, + MOTOR_CHANNELS, + Axis, + AxisLimits, + BeamlineAxis, + Mode, + SmargopoloInfo, + SmargopoloTransport, +) +from .errors import ( + SmargopoloAxisError, + SmargopoloCommandError, + SmargopoloConnectionError, + SmargopoloModeError, + SmargopoloTimeoutError, + SmargopoloTransportError, +) +from .fake import FakeTransport +from .rest import RestTransport + +__all__ = [ + "Axis", + "AxisLimits", + "AXIS_INFO", + "BeamlineAxis", + "Mode", + "MOTOR_CHANNELS", + "SmargopoloInfo", + "SmargopoloTransport", + "FakeTransport", + "RestTransport", + "SmargopoloTransportError", + "SmargopoloConnectionError", + "SmargopoloTimeoutError", + "SmargopoloModeError", + "SmargopoloCommandError", + "SmargopoloAxisError", +] diff --git a/csaxs_bec/devices/smargon/transport/base.py b/csaxs_bec/devices/smargon/transport/base.py new file mode 100644 index 00000000..530d42a6 --- /dev/null +++ b/csaxs_bec/devices/smargon/transport/base.py @@ -0,0 +1,217 @@ +"""Transport abstraction for the SmarGon goniometer via the smargopolo server. + +This module is intentionally free of any ophyd / BEC imports so it can be unit +tested without hardware and so the ophyd device depends only on this small +interface. The device talks to the goniometer exclusively through the +:class:`SmargopoloTransport` interface; concrete implementations are: + +* :class:`~csaxs_bec.devices.smargon.transport.rest.RestTransport` -- the real + client against the smargopolo RESTful API (``http://:``), and +* :class:`~csaxs_bec.devices.smargon.transport.fake.FakeTransport` -- an in-memory + simulator used for offline mode and the test-suite. + +Coordinate frames +----------------- +smargopolo runs the kinematics; we drive the *virtual* SmarGon Coordinate System +(SCS) axes, never the underlying q1..q6 motor stages. The frames are: + +* **SCS** -- the user axes: ``SHX SHY SHZ OMEGA CHI PHI OX OY OZ`` (mm and deg). + Settable via ``/targetSCS`` (+ ``/targetSCS_rel``), read via ``/readbackSCS``. +* **MCS** -- the raw motor positions ``q1..q6``, read-only via ``/readbackMCS`` + (diagnostics only; we never command them directly). +* **BCS** -- beamline-frame nudges ``BX BY BZ`` via ``/nudgeBCS`` / ``/nudgeOBCS`` + (relative only; no absolute target or readback). + +Units: linear axes in mm, angular axes in deg. +""" + +from __future__ import annotations + +import abc +import enum +from dataclasses import dataclass, field + + +class Axis(str, enum.Enum): + """The SmarGon Coordinate System (SCS) axes. + + ``OMEGA`` is optional hardware: some SLS SmarGons have a rotation drive, some + do not. The ophyd device enables/disables it per deployment via config; the + transport supports it unconditionally. + """ + + SHX = "SHX" + SHY = "SHY" + SHZ = "SHZ" + OMEGA = "OMEGA" + CHI = "CHI" + PHI = "PHI" + OX = "OX" + OY = "OY" + OZ = "OZ" + + +class BeamlineAxis(str, enum.Enum): + """Beamline Coordinate System (BCS) axes -- nudge-only.""" + + BX = "BX" + BY = "BY" + BZ = "BZ" + + +# Raw motor channels reported by ``/readbackMCS`` (read-only diagnostics). +MOTOR_CHANNELS: tuple[str, ...] = ("q1", "q2", "q3", "q4", "q5", "q6") + + +class Mode(enum.IntEnum): + """The smargopolo overall state machine (``/mode``).""" + + UNINITIALIZED = 0 + REFERENCING = 1 + READY = 2 # "Follow Target" -- the only mode in which moves are honoured + ERROR = 99 + + +@dataclass(frozen=True) +class AxisLimits: + """Inclusive low/high travel limits for an SCS axis, plus its unit. + + Authoritative SCS travel ranges depend on the specific SmarGon build and are + not published in the REST API docs. The defaults below are deliberately + ``None`` (unbounded): per-deployment soft limits should be supplied via the + device config and are enforced in the ophyd layer. ``egu`` is informational. + """ + + egu: str + low: float | None = None + high: float | None = None + + +# Engineering units per SCS axis (linear in mm, angular in deg). Limits are left +# unbounded by default -- see :class:`AxisLimits`. +AXIS_INFO: dict[Axis, AxisLimits] = { + Axis.SHX: AxisLimits(egu="mm"), + Axis.SHY: AxisLimits(egu="mm"), + Axis.SHZ: AxisLimits(egu="mm"), + Axis.OMEGA: AxisLimits(egu="deg"), + Axis.CHI: AxisLimits(egu="deg"), + Axis.PHI: AxisLimits(egu="deg"), + Axis.OX: AxisLimits(egu="mm"), + Axis.OY: AxisLimits(egu="mm"), + Axis.OZ: AxisLimits(egu="deg"), +} + + +@dataclass +class SmargopoloInfo: + """Identity / health snapshot read from ``/readbackMCS``.""" + + mode: Mode = Mode.UNINITIALIZED + serial_number: str = "" + corr_type: int = 0 + seq: int = 0 # loop counter; should advance if the ROS control loop is alive + rosout: str = "" # last message logged on /rosout (carries error detail) + motor_positions: dict[str, float] = field(default_factory=dict) + + +class SmargopoloTransport(abc.ABC): + """Control-plane interface to a SmarGon goniometer via smargopolo. + + A transport owns the HTTP session lifecycle, exposes the ``/mode`` state + machine (including referencing), drives the SCS axes (absolute and relative + targets), reads back SCS positions and raw motor positions, and applies BCS + nudges. + + All methods raise subclasses of :class:`SmargopoloTransportError` on failure. + Implementations must be safe to call from a single controlling thread; the + ophyd device serialises access behind a shared lock. + """ + + # -- session lifecycle ------------------------------------------------- + + @abc.abstractmethod + def connect(self, *, timeout: float | None = None) -> None: + """Open/verify the HTTP session with smargopolo. + + Idempotent. Does **not** reference the goniometer -- referencing is a + deliberate operator action (see :meth:`set_mode`). + """ + + @abc.abstractmethod + def close(self) -> None: + """Release the session. Idempotent and must never raise.""" + + @property + @abc.abstractmethod + def connected(self) -> bool: + """Whether the server is reachable (last contact succeeded).""" + + # -- state machine ----------------------------------------------------- + + @abc.abstractmethod + def get_mode(self) -> Mode: + """Return the current state-machine mode.""" + + @abc.abstractmethod + def set_mode(self, mode: Mode) -> None: + """Set the state-machine mode (``PUT /mode?mode=N``). + + Setting :attr:`Mode.REFERENCING` starts referencing, which physically + moves the goniometer to find its reference marks before transitioning to + :attr:`Mode.READY`. + """ + + @abc.abstractmethod + def get_info(self) -> SmargopoloInfo: + """Return an identity/health snapshot (mode, serial, rosout, motors).""" + + # -- motion ------------------------------------------------------------ + + @abc.abstractmethod + def get_position(self, axis: Axis) -> float: + """Return the current readback of *axis* in its SCS unit.""" + + @abc.abstractmethod + def get_positions(self) -> dict[Axis, float]: + """Return the full SCS readback as ``{Axis: value}``.""" + + @abc.abstractmethod + def move_absolute(self, targets: dict[Axis, float]) -> None: + """Command an absolute SCS move of one or more axes (non-blocking). + + Returns as soon as the target is accepted (smargopolo replies ``202``); + completion is observed by the caller polling :meth:`get_position`. + Requires :attr:`Mode.READY` -- otherwise raises + :class:`~csaxs_bec.devices.smargon.transport.errors.SmargopoloModeError`. + """ + + @abc.abstractmethod + def move_relative(self, deltas: dict[Axis, float]) -> None: + """Command a relative SCS move (``/targetSCS_rel``). Requires READY.""" + + @abc.abstractmethod + def stop(self, axes: list[Axis] | None = None) -> None: + """Halt *axes* (or all SCS axes) by retargeting them to their readback. + + smargopolo exposes no explicit stop endpoint; in ``Mode.READY`` ("Follow + Target") setting the target equal to the current readback brings the axis + to rest. Best-effort: must not raise during teardown. + """ + + # -- diagnostics ------------------------------------------------------- + + @abc.abstractmethod + def get_motor_positions(self) -> dict[str, float]: + """Return the raw ``q1..q6`` motor positions (read-only).""" + + # -- beamline-frame nudges -------------------------------------------- + + @abc.abstractmethod + def nudge_beamline( + self, deltas: dict[BeamlineAxis, float], *, modify_offset: bool = False + ) -> None: + """Nudge the sample along the beamline axes (``/nudgeBCS``). + + With ``modify_offset=True`` the O-vector is nudged instead + (``/nudgeOBCS``). Relative only. Requires READY. + """ diff --git a/csaxs_bec/devices/smargon/transport/errors.py b/csaxs_bec/devices/smargon/transport/errors.py new file mode 100644 index 00000000..8908d6b2 --- /dev/null +++ b/csaxs_bec/devices/smargon/transport/errors.py @@ -0,0 +1,37 @@ +"""Typed exceptions for the smargopolo transport layer. + +Defined in a tiny, dependency-free module so that both the transport +implementations and the ophyd device can import them without pulling in ophyd or +any networking stack. +""" + +from __future__ import annotations + + +class SmargopoloTransportError(Exception): + """Base class for all smargopolo transport errors.""" + + +class SmargopoloConnectionError(SmargopoloTransportError): + """Raised when the transport cannot reach the smargopolo server (HTTP failure).""" + + +class SmargopoloTimeoutError(SmargopoloTransportError): + """Raised when a request to the smargopolo server exceeds its timeout.""" + + +class SmargopoloModeError(SmargopoloTransportError): + """Raised when a command is attempted in the wrong state-machine mode. + + smargopolo only follows position targets in ``Mode.READY``. Commanding motion + while ``UNINITIALIZED``/``REFERENCING``/``ERROR`` raises this so the caller can + surface "reference the goniometer first" rather than silently doing nothing. + """ + + +class SmargopoloCommandError(SmargopoloTransportError): + """Raised when the server rejects a command or returns an error response.""" + + +class SmargopoloAxisError(SmargopoloCommandError): + """Raised when an axis name is unknown or a target is otherwise invalid.""" diff --git a/csaxs_bec/devices/smargon/transport/fake.py b/csaxs_bec/devices/smargon/transport/fake.py new file mode 100644 index 00000000..7fcc1e40 --- /dev/null +++ b/csaxs_bec/devices/smargon/transport/fake.py @@ -0,0 +1,225 @@ +"""In-memory fake smargopolo transport. + +:class:`FakeTransport` implements the full :class:`SmargopoloTransport` interface +with no network access. It backs two things: + +* **offline / simulation mode** -- set ``simulation: true`` in the device config + and the ophyd device loads without a real smargopolo server, and +* **the unit-test suite** -- tests inject it (or assert against it) instead of + touching hardware. + +It models the ``/mode`` state machine: it starts ``UNINITIALIZED`` and refuses +motion until referenced. ``set_mode(REFERENCING)`` transitions to ``READY`` +(instantly by default, or after ``reference_time`` of simulated travel). + +Motion is *instant* by default; pass ``gradual_motion=True`` to advance positions +toward target at a finite speed against the injected clock, for exercising the +device's polling / status path. +""" + +from __future__ import annotations + +from typing import Callable + +from .base import ( + AXIS_INFO, + MOTOR_CHANNELS, + Axis, + BeamlineAxis, + Mode, + SmargopoloInfo, + SmargopoloTransport, +) +from .errors import SmargopoloModeError + +# A plausible home pose (matches the example script's home target). +_DEFAULT_POSITIONS: dict[Axis, float] = { + Axis.SHX: 0.0, + Axis.SHY: 0.0, + Axis.SHZ: 18.0, + Axis.OMEGA: 0.0, + Axis.CHI: 0.0, + Axis.PHI: 0.0, + Axis.OX: 0.0, + Axis.OY: 0.0, + Axis.OZ: 180.0, +} + +# Default move speed (axis-units/s) when ``gradual_motion`` is on. +_DEFAULT_SPEED = 50.0 + + +class FakeTransport(SmargopoloTransport): + """A deterministic, hardware-free :class:`SmargopoloTransport` implementation.""" + + def __init__( + self, + *, + gradual_motion: bool = False, + reference_time: float = 0.0, + serial_number: str = "MCS2-FAKE0001", + time_func: Callable[[], float] | None = None, + ) -> None: + import time as _time + + self._time = time_func or _time.monotonic + self._gradual = gradual_motion + self._reference_time = float(reference_time) + self._serial = serial_number + + self._connected = False + self._mode = Mode.UNINITIALIZED + self._seq = 0 + self._rosout = "" + + self._positions = dict(_DEFAULT_POSITIONS) + self._targets = dict(_DEFAULT_POSITIONS) + self._move_start_pos: dict[Axis, float] = {} + self._move_start_t: dict[Axis, float] = {} + self._reference_done_t: float | None = None + + # Test hooks: count calls and optionally inject failures. + self.call_log: list[str] = [] + self.fail_next: dict[str, Exception] = {} + + # -- internal helpers -------------------------------------------------- + + def _record(self, name: str) -> None: + self._seq += 1 + self.call_log.append(name) + exc = self.fail_next.pop(name, None) + if exc is not None: + raise exc + + def _settle(self, axis: Axis) -> None: + """Advance gradual-motion state for *axis* to 'now'.""" + if axis not in self._move_start_t: + return + elapsed = max(0.0, self._time() - self._move_start_t[axis]) + start = self._move_start_pos[axis] + target = self._targets[axis] + travelled = _DEFAULT_SPEED * elapsed + if travelled >= abs(target - start): + self._positions[axis] = target + self._move_start_t.pop(axis, None) + self._move_start_pos.pop(axis, None) + else: + direction = 1.0 if target >= start else -1.0 + self._positions[axis] = start + direction * travelled + + def _settle_referencing(self) -> None: + if self._mode is not Mode.REFERENCING or self._reference_done_t is None: + return + if self._time() >= self._reference_done_t: + self._mode = Mode.READY + self._reference_done_t = None + + def _require_ready(self) -> None: + self._settle_referencing() + if self._mode is not Mode.READY: + raise SmargopoloModeError( + f"smargopolo is in {self._mode.name} (mode {int(self._mode)}); reference the " + "goniometer (set Mode.READY) before commanding motion" + ) + + def _start_move(self, axis: Axis, value: float) -> None: + self._targets[axis] = value + if not self._gradual: + self._positions[axis] = value + return + self._move_start_pos[axis] = self._positions[axis] + self._move_start_t[axis] = self._time() + + # -- session lifecycle ------------------------------------------------- + + def connect(self, *, timeout: float | None = None) -> None: + self._record("connect") + self._connected = True + + def close(self) -> None: + self._record("close") + self._connected = False + + @property + def connected(self) -> bool: + return self._connected + + # -- state machine ----------------------------------------------------- + + def get_mode(self) -> Mode: + self._settle_referencing() + return self._mode + + def set_mode(self, mode: Mode) -> None: + self._record(f"set_mode:{Mode(mode).name}") + mode = Mode(mode) + if mode is Mode.REFERENCING: + if self._reference_time > 0 and self._gradual: + self._mode = Mode.REFERENCING + self._reference_done_t = self._time() + self._reference_time + else: + self._mode = Mode.READY # instant referencing + else: + self._mode = mode + + def get_info(self) -> SmargopoloInfo: + return SmargopoloInfo( + mode=self.get_mode(), + serial_number=self._serial, + corr_type=1, + seq=self._seq, + rosout=self._rosout, + motor_positions=self.get_motor_positions(), + ) + + # -- motion ------------------------------------------------------------ + + def get_position(self, axis: Axis) -> float: + axis = Axis(axis) + if self._gradual: + self._settle(axis) + return self._positions[axis] + + def get_positions(self) -> dict[Axis, float]: + return {a: self.get_position(a) for a in Axis} + + def move_absolute(self, targets: dict[Axis, float]) -> None: + self._record("move_absolute") + self._require_ready() + for axis, value in targets.items(): + self._start_move(Axis(axis), float(value)) + + def move_relative(self, deltas: dict[Axis, float]) -> None: + self._record("move_relative") + self._require_ready() + for axis, delta in deltas.items(): + axis = Axis(axis) + self._start_move(axis, self.get_position(axis) + float(delta)) + + def stop(self, axes: list[Axis] | None = None) -> None: + self._record("stop") + chosen = list(axes) if axes is not None else list(Axis) + for axis in chosen: + axis = Axis(axis) + if self._gradual: + self._settle(axis) + self._targets[axis] = self._positions[axis] + self._move_start_t.pop(axis, None) + + # -- diagnostics ------------------------------------------------------- + + def get_motor_positions(self) -> dict[str, float]: + # No real kinematics in the fake; surface a stable, inspectable mapping. + return {q: 0.0 for q in MOTOR_CHANNELS} + + # -- beamline-frame nudges -------------------------------------------- + + def nudge_beamline( + self, deltas: dict[BeamlineAxis, float], *, modify_offset: bool = False + ) -> None: + self._record("nudge_obcs" if modify_offset else "nudge_bcs") + self._require_ready() + # The fake does not model the BCS->SCS kinematics; nudges are accepted as + # no-ops on the SCS readback so callers can exercise the code path. + for _axis in deltas: + BeamlineAxis(_axis) # validate the axis name diff --git a/csaxs_bec/devices/smargon/transport/rest.py b/csaxs_bec/devices/smargon/transport/rest.py new file mode 100644 index 00000000..507adffc --- /dev/null +++ b/csaxs_bec/devices/smargon/transport/rest.py @@ -0,0 +1,212 @@ +"""Real smargopolo RESTful-API transport. + +Standard-library only (``urllib``); imports neither ophyd nor any third-party +networking stack, mirroring the rest of the transport layer. Talks to the +smargopolo server documented in ``projects/smargon/resources`` -- e.g.:: + + GET http://smargopolo:3000/readbackSCS + PUT http://smargopolo:3000/targetSCS?SHX=0.1&SHZ=18 + PUT http://smargopolo:3000/mode?mode=1 + +PUTs return ``202 Accepted`` with the updated target (not a motion confirmation); +move completion is observed by the device polling :meth:`get_position`. +""" + +from __future__ import annotations + +import json +import socket +import urllib.error +import urllib.parse +import urllib.request + +from .base import ( + AXIS_INFO, + MOTOR_CHANNELS, + Axis, + BeamlineAxis, + Mode, + SmargopoloInfo, + SmargopoloTransport, +) +from .errors import ( + SmargopoloCommandError, + SmargopoloConnectionError, + SmargopoloModeError, + SmargopoloTimeoutError, +) + + +class RestTransport(SmargopoloTransport): + """:class:`SmargopoloTransport` backed by the smargopolo HTTP REST API.""" + + def __init__(self, *, host: str = "smargopolo", port: int = 3000, timeout: float = 5.0) -> None: + self._host = host + self._port = int(port) + self._timeout = float(timeout) + self._base = f"http://{host}:{self._port}" + self._connected = False + + # -- HTTP helpers ------------------------------------------------------ + + def _url(self, resource: str, params: dict | None = None) -> str: + url = f"{self._base}/{resource.lstrip('/')}" + if params: + # smargopolo expects bare ``?A=1&B=2`` query strings. + url += "?" + urllib.parse.urlencode(params) + return url + + def _request(self, resource: str, *, method: str = "GET", params: dict | None = None) -> dict: + url = self._url(resource, params) + req = urllib.request.Request(url, method=method) + try: + with urllib.request.urlopen(req, timeout=self._timeout) as resp: + body = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + self._connected = True # we reached the server; it returned an error + raise SmargopoloCommandError( + f"{method} {url} -> HTTP {exc.code}: {exc.reason}" + ) from exc + except socket.timeout as exc: + self._connected = False + raise SmargopoloTimeoutError( + f"{method} {url} timed out after {self._timeout}s" + ) from exc + except urllib.error.URLError as exc: + self._connected = False + raise SmargopoloConnectionError(f"{method} {url} failed: {exc.reason}") from exc + self._connected = True + if not body: + return {} + try: + return json.loads(body) + except json.JSONDecodeError as exc: + raise SmargopoloCommandError(f"{method} {url} returned non-JSON: {body!r}") from exc + + def _get(self, resource: str) -> dict: + return self._request(resource, method="GET") + + def _put(self, resource: str, params: dict) -> dict: + return self._request(resource, method="PUT", params=params) + + # -- session lifecycle ------------------------------------------------- + + def connect(self, *, timeout: float | None = None) -> None: + # A successful readback confirms the server is up and the loop is running. + self._get("readbackMCS") + self._connected = True + + def close(self) -> None: + self._connected = False + + @property + def connected(self) -> bool: + return self._connected + + # -- state machine ----------------------------------------------------- + + def get_mode(self) -> Mode: + data = self._get("readbackMCS") + return self._coerce_mode(data.get("mode")) + + def set_mode(self, mode: Mode) -> None: + self._put("mode", {"mode": int(mode)}) + + def get_info(self) -> SmargopoloInfo: + data = self._get("readbackMCS") + rosout = data.get("rosout") or {} + position = data.get("position") or {} + return SmargopoloInfo( + mode=self._coerce_mode(data.get("mode")), + serial_number=data.get("serial_number", "") or "", + corr_type=int(data.get("corr_type", 0) or 0), + seq=int(data.get("seq", 0) or 0), + rosout=rosout.get("msg", "") or "", + motor_positions={q: float(position.get(q, 0.0) or 0.0) for q in MOTOR_CHANNELS}, + ) + + @staticmethod + def _coerce_mode(value) -> Mode: + try: + return Mode(int(value)) + except (TypeError, ValueError): + return Mode.ERROR + + # -- motion ------------------------------------------------------------ + + def get_position(self, axis: Axis) -> float: + return self.get_positions()[Axis(axis)] + + def get_positions(self) -> dict[Axis, float]: + data = self._get("readbackSCS") + out: dict[Axis, float] = {} + for axis in Axis: + if axis.value in data: + out[axis] = float(data[axis.value]) + return out + + def _require_ready(self) -> None: + mode = self.get_mode() + if mode is not Mode.READY: + raise SmargopoloModeError( + f"smargopolo is in {mode.name} (mode {int(mode)}); reference the goniometer " + "(set Mode.READY) before commanding motion" + ) + + def move_absolute(self, targets: dict[Axis, float]) -> None: + if not targets: + return + self._require_ready() + self._put("targetSCS", {Axis(a).value: float(v) for a, v in targets.items()}) + + def move_relative(self, deltas: dict[Axis, float]) -> None: + if not deltas: + return + self._require_ready() + self._put("targetSCS_rel", {Axis(a).value: float(v) for a, v in deltas.items()}) + + def stop(self, axes: list[Axis] | None = None) -> None: + # Retarget the chosen axes to their current readback: in "Follow Target" + # mode this halts them. Best-effort -- never raise. + try: + positions = self.get_positions() + except Exception: # pylint: disable=broad-except + return + chosen = list(axes) if axes is not None else list(positions) + targets = {a: positions[a] for a in chosen if a in positions} + if not targets: + return + try: + self._put("targetSCS", {Axis(a).value: float(v) for a, v in targets.items()}) + except Exception: # pylint: disable=broad-except + pass + + # -- diagnostics ------------------------------------------------------- + + def get_motor_positions(self) -> dict[str, float]: + return self.get_info().motor_positions + + # -- beamline-frame nudges -------------------------------------------- + + def nudge_beamline( + self, deltas: dict[BeamlineAxis, float], *, modify_offset: bool = False + ) -> None: + if not deltas: + return + self._require_ready() + resource = "nudgeOBCS" if modify_offset else "nudgeBCS" + self._put(resource, {BeamlineAxis(a).value: float(v) for a, v in deltas.items()}) + + # -- introspection ----------------------------------------------------- + + @property + def host(self) -> str: + return self._host + + @property + def port(self) -> int: + return self._port + + +# Re-exported for callers that want axis units without importing base directly. +__all__ = ["RestTransport", "AXIS_INFO"] diff --git a/tests/tests_devices/test_smargon_device.py b/tests/tests_devices/test_smargon_device.py new file mode 100644 index 00000000..64a1eda8 --- /dev/null +++ b/tests/tests_devices/test_smargon_device.py @@ -0,0 +1,162 @@ +"""Tests for the SmarGon ophyd device (simulation mode, no hardware).""" + +import pytest +from ophyd import Kind +from ophyd.utils import LimitError + +from csaxs_bec.devices.smargon import SmarGon +from csaxs_bec.devices.smargon.transport import FakeTransport, Mode +from csaxs_bec.devices.smargon.transport.errors import SmargopoloCommandError, SmargopoloModeError + + +@pytest.fixture +def smargon(): + device = SmarGon(name="smargon_test", simulation=True, has_omega=True) + device.wait_for_connection() + yield device + device.destroy() + + +def test_uses_fake_transport(smargon): + assert isinstance(smargon.transport, FakeTransport) + assert smargon.transport.connected + + +def test_v1_axes_are_positioners(smargon): + for ax in (smargon.shx, smargon.shy, smargon.shz, smargon.chi, smargon.phi): + assert hasattr(ax, "move") and hasattr(ax, "stop") + assert ax.kind != Kind.omitted + + +def test_refuses_move_until_referenced(smargon): + assert smargon.get_mode() == "UNINITIALIZED" + with pytest.raises(SmargopoloModeError): + smargon.shx.move(0.5) + + +def test_reference_then_move(smargon): + smargon.reference() + assert smargon.is_ready() + status = smargon.shx.move(0.5, wait=True) + assert status.done and status.success + assert smargon.shx.position == pytest.approx(0.5) + + +def test_set_alias_is_nonblocking(smargon): + smargon.reference() + status = smargon.chi.set(12.0) + status.wait(timeout=5) + assert status.success + assert smargon.chi.position == pytest.approx(12.0) + + +def test_omega_enabled_when_has_omega(smargon): + assert smargon.omega.kind != Kind.omitted + smargon.reference() + smargon.omega.move(45.0, wait=True) + assert smargon.omega.position == pytest.approx(45.0) + + +def test_omega_disabled_without_has_omega(): + device = SmarGon(name="no_omega", simulation=True, has_omega=False) + device.wait_for_connection() + try: + device.reference() + assert device.omega.kind == Kind.omitted + with pytest.raises(SmargopoloModeError): + device.omega.move(45.0) + finally: + device.destroy() + + +def test_soft_limits_enforced(): + device = SmarGon(name="lim", simulation=True, limits={"SHX": [-1.0, 1.0]}) + device.wait_for_connection() + try: + device.reference() + assert device.shx.limits == (-1.0, 1.0) + with pytest.raises(LimitError): + device.shx.move(2.0) + finally: + device.destroy() + + +def test_per_axis_tolerance_applied(): + device = SmarGon(name="tol", simulation=True, tolerance={"SHX": 0.05}) + device.wait_for_connection() + try: + assert device.shx._tolerance == pytest.approx(0.05) + finally: + device.destroy() + + +def test_move_scs_coordinated(smargon): + smargon.reference() + smargon.move_scs(SHX=0.3, SHZ=19.0, wait=True) + assert smargon.shx.position == pytest.approx(0.3) + assert smargon.shz.position == pytest.approx(19.0) + + +def test_stop_all_does_not_raise(smargon): + smargon.reference() + smargon.shx.set(0.2) + smargon.stop_all() + + +def test_motor_positions_diagnostic(smargon): + motors = smargon.motor_positions() + assert set(motors) == {"q1", "q2", "q3", "q4", "q5", "q6"} + + +def test_transport_info(smargon): + info = smargon.transport_info() + assert info["simulation"] is True + assert info["has_omega"] is True + assert info["connected"] is True + + +def test_move_fails_when_target_rejected_up_front(smargon): + """smargopolo refusing an out-of-range target -> immediate move failure.""" + smargon.reference() + smargon.transport.fail_next["move_absolute"] = SmargopoloCommandError("target out of range") + status = smargon.shx.set(0.5) + with pytest.raises(SmargopoloCommandError): + status.wait(timeout=5) + assert not status.success + + +def test_move_aborts_when_controller_faults_mid_move(): + """Target accepted (202) but a coupled-limit fault appears after partial motion.""" + device = SmarGon(name="faulter", simulation=True, gradual_motion=True) + device.wait_for_connection() + try: + device.reference() + status = device.shx.set(1e6) # far target: gradual motion keeps it in-flight + # Simulate smargopolo hitting a coupled hardware limit mid-move. + device.transport._mode = Mode.ERROR + with pytest.raises(SmargopoloModeError): + status.wait(timeout=5) + assert not status.success + assert device.shx.motor_is_moving.get() == 0 + finally: + device.destroy() + + +def test_move_scs_aborts_on_controller_fault(): + device = SmarGon(name="faulter2", simulation=True, gradual_motion=True) + device.wait_for_connection() + try: + device.reference() + device.transport._mode = Mode.ERROR + with pytest.raises(SmargopoloModeError): + device.move_scs(SHX=1e6, wait=True) + finally: + device.destroy() + + +def test_mode_signal_is_readonly(smargon): + from ophyd.utils import ReadOnlyError + + assert smargon.mode.get() == int(Mode.UNINITIALIZED) + with pytest.raises(ReadOnlyError): + smargon.mode.put(2) diff --git a/tests/tests_devices/test_smargon_transport.py b/tests/tests_devices/test_smargon_transport.py new file mode 100644 index 00000000..8aba62c6 --- /dev/null +++ b/tests/tests_devices/test_smargon_transport.py @@ -0,0 +1,82 @@ +"""Tests for the ophyd-free smargopolo transport layer (FakeTransport).""" + +import pytest + +from csaxs_bec.devices.smargon.transport import Axis, BeamlineAxis, FakeTransport, Mode +from csaxs_bec.devices.smargon.transport.errors import SmargopoloModeError + + +@pytest.fixture +def transport(): + t = FakeTransport() + t.connect() + return t + + +def test_starts_uninitialized_and_refuses_motion(transport): + assert transport.get_mode() is Mode.UNINITIALIZED + with pytest.raises(SmargopoloModeError): + transport.move_absolute({Axis.SHX: 0.5}) + + +def test_referencing_transitions_to_ready(transport): + transport.set_mode(Mode.REFERENCING) + assert transport.get_mode() is Mode.READY + + +def test_absolute_move_updates_readback(transport): + transport.set_mode(Mode.REFERENCING) + transport.move_absolute({Axis.SHX: 0.5, Axis.CHI: 10.0}) + assert transport.get_position(Axis.SHX) == pytest.approx(0.5) + assert transport.get_position(Axis.CHI) == pytest.approx(10.0) + + +def test_relative_move(transport): + transport.set_mode(Mode.REFERENCING) + start = transport.get_position(Axis.SHY) + transport.move_relative({Axis.SHY: 0.2}) + assert transport.get_position(Axis.SHY) == pytest.approx(start + 0.2) + + +def test_get_positions_covers_all_scs_axes(transport): + positions = transport.get_positions() + assert set(positions) == set(Axis) + + +def test_stop_retargets_to_readback(transport): + transport.set_mode(Mode.REFERENCING) + transport.move_absolute({Axis.SHZ: 20.0}) + transport.stop([Axis.SHZ]) + assert transport.get_position(Axis.SHZ) == pytest.approx(20.0) + + +def test_info_reports_serial_and_motors(transport): + info = transport.get_info() + assert info.serial_number == "MCS2-FAKE0001" + assert set(info.motor_positions) == {"q1", "q2", "q3", "q4", "q5", "q6"} + + +def test_seq_advances_on_commands(transport): + first = transport.get_info().seq + transport.set_mode(Mode.REFERENCING) + transport.move_absolute({Axis.SHX: 0.1}) + assert transport.get_info().seq > first + + +def test_nudge_requires_ready(transport): + with pytest.raises(SmargopoloModeError): + transport.nudge_beamline({BeamlineAxis.BX: 0.01}) + transport.set_mode(Mode.REFERENCING) + transport.nudge_beamline({BeamlineAxis.BX: 0.01}) # no raise once READY + + +def test_gradual_motion_advances_over_time(): + clock = {"t": 0.0} + t = FakeTransport(gradual_motion=True, time_func=lambda: clock["t"]) + t.connect() + t.set_mode(Mode.REFERENCING) + t.move_absolute({Axis.SHX: 100.0}) # 50 units/s default speed + clock["t"] = 1.0 + assert t.get_position(Axis.SHX) == pytest.approx(50.0) + clock["t"] = 5.0 + assert t.get_position(Axis.SHX) == pytest.approx(100.0) -- 2.54.0 From a4b6285e608b98e192bd4154e2cea653376543de Mon Sep 17 00:00:00 2001 From: menzel Date: Mon, 6 Jul 2026 11:24:27 +0200 Subject: [PATCH 2/6] feat(smargon): ground SCS limits + host guidance from MX-lab reference client resources_2/Smargon.py (the MX-lab SmarGonController) gives real per-axis SCS limits and the server hostname convention. Update smargon.yaml: - SHX/SHY [-2,2], SHZ [10,22] (home ~18, not centred on 0), CHI [0,65], PHI [-180,180] (was placeholder [-2.5,2.5] for the SH axes; SHZ was wrong). - host: note it must be the actual reachable server (ref used mxlab1-smargopolo). - Note PHI is continuous and the reference client wraps to [-180,180) before sending -- flagged as an open design question, not yet handled here. Still DRAFT / untested against hardware. Co-Authored-By: Claude Opus 4.8 (1M context) --- csaxs_bec/device_configs/smargon.yaml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/csaxs_bec/device_configs/smargon.yaml b/csaxs_bec/device_configs/smargon.yaml index b0d23421..b1382e84 100644 --- a/csaxs_bec/device_configs/smargon.yaml +++ b/csaxs_bec/device_configs/smargon.yaml @@ -9,7 +9,9 @@ smargon: description: SmarGon goniometer (SCS axes via smargopolo REST API) deviceClass: csaxs_bec.devices.smargon.smargon.SmarGon deviceConfig: - host: smargopolo # smargopolo server hostname + host: smargopolo # smargopolo server hostname -- SET TO THE ACTUAL SERVER + # (the MX-lab reference client used "mxlab1-smargopolo"; + # use the hostname reachable from the beamline network) port: 3000 # REST API port timeout: 5.0 # per-request transport timeout [s] has_omega: false # set true on builds with the OMEGA rotation drive @@ -27,10 +29,17 @@ smargon: # PUT is rejected, or -- if the fault only shows after partial motion -- the # device aborts the move on the controller's ERROR mode). Omit an axis here to # leave it unbounded in BEC and rely on the controller. + # + # Values below are taken from the MX-lab reference client (resources_2/Smargon.py) + # -- a good grounded starting point, but VERIFY against this build. Note SHZ is + # NOT centred on 0 (home is SHZ~18), and PHI is a continuous axis the reference + # client wraps to [-180, 180) before sending (see the phi-wrap note in the docs). limits: - SHX: [-2.5, 2.5] - SHY: [-2.5, 2.5] - SHZ: [-2.5, 2.5] + SHX: [-2, 2] + SHY: [-2, 2] + SHZ: [10, 22] + CHI: [0, 65] + PHI: [-180, 180] poll_interval: 0.1 # readback polling cadence while moving [s] move_timeout: 60.0 # per-move timeout [s] deviceTags: -- 2.54.0 From 73329badb4bbd7737255ef783757081f077b1c28 Mon Sep 17 00:00:00 2001 From: menzel Date: Mon, 6 Jul 2026 14:19:24 +0200 Subject: [PATCH 3/6] refactor(smargon): per-axis motors + shared controller (smaract pattern) Expose each SCS axis as its own top-level BEC motor sharing a singleton SmargopoloController per host:port, plus a thin `dev.smargon` coordinator for whole-goniometer ops. Replaces the single-device-with-axis-subcomponents design. Why: BEC's motor UX (dev.wm, umv, limits, GUI motor widgets) is keyed on top-level devices. With sub-components, dev.wm showed `readback N/A` (it looks up a key equal to the device name) and `limits [0,0]` (the client reads limits from the root device's low/high_limit_travel signals). General, not a sim artifact. - controller.py: SmargopoloController(OphydObject), singleton per (host,port,sim); owns transport + lock + mode; referencing / move_scs / nudge / q1..q6 diagnostics. Reachable as dev..controller.() (USER_ACCESS). - positioner.py: SmargopoloMotor(Device, PositionerBase) -- top-level per-axis motor. scs_axis field binds the (user-chosen) BEC name to the fixed SCS axis. Exposes low/high_limit_travel signals and aliases readback to the device name, so dev.wm shows real per-axis readback + limits (low==high => unbounded). egu auto from axis. - smargon.py: SmarGon(PSIDeviceBase) reduced to a thin coordinator delegating to the shared controller; keeps read-only mode/rosout signals. - YAML: smargon.yaml + smargon_sim_session.yaml rewritten to per-axis entries (sgx/sgy/sgz/sgchi/sgphi[/sgomega]) + coordinator. Names user-configurable; SCS binding explicit via scs_axis. - transport/ layer + fake unchanged. Tests rewritten: 29 pass vs FakeTransport (singleton reset fixture; covers sharing, wm readback aliasing, limits, fault paths). Still DRAFT / untested against hardware. Co-Authored-By: Claude Opus 4.8 (1M context) --- csaxs_bec/device_configs/smargon.yaml | 126 ++++--- .../device_configs/smargon_sim_session.yaml | 89 +++++ csaxs_bec/devices/smargon/README.md | 114 ++++--- csaxs_bec/devices/smargon/__init__.py | 15 +- csaxs_bec/devices/smargon/controller.py | 284 ++++++++++++++++ csaxs_bec/devices/smargon/positioner.py | 161 ++++----- csaxs_bec/devices/smargon/smargon.py | 315 +++--------------- tests/tests_devices/test_smargon_device.py | 280 +++++++++------- 8 files changed, 810 insertions(+), 574 deletions(-) create mode 100644 csaxs_bec/device_configs/smargon_sim_session.yaml create mode 100644 csaxs_bec/devices/smargon/controller.py diff --git a/csaxs_bec/device_configs/smargon.yaml b/csaxs_bec/device_configs/smargon.yaml index b1382e84..bcbe8897 100644 --- a/csaxs_bec/device_configs/smargon.yaml +++ b/csaxs_bec/device_configs/smargon.yaml @@ -1,70 +1,86 @@ # Example BEC device-config entries for the SmarGon goniometer (via smargopolo). # -# The device controls the virtual SCS axes; smargopolo runs the kinematics and -# drives the underlying q1..q6 stages. Referencing is a deliberate operator action: -# after loading, call `smargon.reference()` once smargopolo is up. +# Per-axis architecture (like the smaract motors): each SCS axis is its own top-level +# motor sharing ONE SmargopoloController (singleton per host:port), plus a thin +# `smargon` coordinator for whole-goniometer ops. All entries must use the SAME +# host/port so they share the controller. +# +# The motor NAME (the top-level key: sgx, sgchi, ...) is yours to choose. The +# `scs_axis` field binds it to the fixed smargopolo axis (SHX/SHY/SHZ/CHI/PHI/OMEGA). +# +# Referencing is a deliberate operator action: after loading, run once +# dev.smargon.reference() # (or dev.sgx.controller.reference()) -# --- Real goniometer (smargopolo REST API) --------------------------------------- +# --- Coordinator (whole-goniometer ops: reference/mode/move_scs/diagnostics) ------ smargon: - description: SmarGon goniometer (SCS axes via smargopolo REST API) + description: SmarGon coordinator (referencing, mode, coordinated moves) deviceClass: csaxs_bec.devices.smargon.smargon.SmarGon deviceConfig: - host: smargopolo # smargopolo server hostname -- SET TO THE ACTUAL SERVER - # (the MX-lab reference client used "mxlab1-smargopolo"; - # use the hostname reachable from the beamline network) - port: 3000 # REST API port - timeout: 5.0 # per-request transport timeout [s] - has_omega: false # set true on builds with the OMEGA rotation drive - # Per-move completion tolerance: a float (all axes) or per-axis mapping. - tolerance: - SHX: 0.001 # mm - SHY: 0.001 - SHZ: 0.001 - CHI: 0.01 # deg - PHI: 0.01 - # Soft limits in SCS units [low, high]. These are typically USER-set (per - # experiment), not hardware ranges: the true per-axis travel of a SmarGon is - # coupled across all SCS axes and cannot be reliably pre-declared. smargopolo - # owns the real hardware limits; if a target violates them the move fails (the - # PUT is rejected, or -- if the fault only shows after partial motion -- the - # device aborts the move on the controller's ERROR mode). Omit an axis here to - # leave it unbounded in BEC and rely on the controller. - # - # Values below are taken from the MX-lab reference client (resources_2/Smargon.py) - # -- a good grounded starting point, but VERIFY against this build. Note SHZ is - # NOT centred on 0 (home is SHZ~18), and PHI is a continuous axis the reference - # client wraps to [-180, 180) before sending (see the phi-wrap note in the docs). - limits: - SHX: [-2, 2] - SHY: [-2, 2] - SHZ: [10, 22] - CHI: [0, 65] - PHI: [-180, 180] - poll_interval: 0.1 # readback polling cadence while moving [s] - move_timeout: 60.0 # per-move timeout [s] - deviceTags: - - goniometer - - smargon + host: smargopolo # SET to the actual server (ref client used "mxlab1-smargopolo") + port: 3000 + timeout: 5.0 + deviceTags: [goniometer, smargon] enabled: true onFailure: retry readOnly: false readoutPriority: baseline softwareTrigger: false -# --- Simulation / offline (no server; uses FakeTransport) ------------------------ -smargon_sim: - description: SmarGon goniometer (simulation, no smargopolo server required) - deviceClass: csaxs_bec.devices.smargon.smargon.SmarGon - deviceConfig: - simulation: true - has_omega: true # exercise OMEGA in simulation - gradual_motion: true # simulate finite-speed travel (exercise move status) - deviceTags: - - goniometer - - smargon - - simulation +# --- 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: smargopolo, port: 3000, limits: [-2, 2], tolerance: 0.001} + deviceTags: [goniometer, smargon] enabled: true onFailure: retry - readOnly: false readoutPriority: baseline - softwareTrigger: false + +sgy: + description: SmarGon SHY (sample Y) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: {scs_axis: SHY, host: 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: 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: 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: smargopolo, port: 3000, limits: [-180, 180], tolerance: 0.01} + deviceTags: [goniometer, smargon] + enabled: true + onFailure: retry + readoutPriority: baseline + +# --- Optional OMEGA rotation drive (only on builds that have it) ------------------- +# sgomega: +# description: SmarGon OMEGA (rotation drive) +# deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor +# deviceConfig: {scs_axis: OMEGA, host: smargopolo, port: 3000, tolerance: 0.01} +# deviceTags: [goniometer, smargon] +# enabled: true +# onFailure: retry +# readoutPriority: baseline diff --git a/csaxs_bec/device_configs/smargon_sim_session.yaml b/csaxs_bec/device_configs/smargon_sim_session.yaml new file mode 100644 index 00000000..61b98f12 --- /dev/null +++ b/csaxs_bec/device_configs/smargon_sim_session.yaml @@ -0,0 +1,89 @@ +# Self-contained laptop test session for the SmarGon device (SIMULATION). +# +# Per-axis motors (dev.sgx, dev.sgy, dev.sgz, dev.sgchi, dev.sgphi, dev.sgomega) + +# a `smargon` coordinator, all sharing one simulated controller, plus a sim monitor +# so scans record data. No smargopolo server / hardware needed. +# +# Load in the client: +# bec.config.update_session_with_file( +# '/Users/menzel/BEC_development/stack/csaxs_bec-smargon/csaxs_bec/device_configs/smargon_sim_session.yaml') +# Then: dev.smargon.reference(); umv(dev.sgx, 0.5); dev.wm('sg*') +# +# NOTE: update_session_with_file REPLACES the current session. PREREQUISITE: the +# running BEC must import csaxs_bec.devices.smargon (repoint the editable install at +# the smargon worktree; see RUNNING_BEC.md). + +smargon: + description: SmarGon coordinator (SIMULATION) + deviceClass: csaxs_bec.devices.smargon.smargon.SmarGon + deviceConfig: {simulation: true, gradual_motion: true} + deviceTags: [goniometer, smargon, simulation] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgx: + description: SmarGon SHX (sim) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: {scs_axis: SHX, simulation: true, gradual_motion: true, limits: [-2, 2], tolerance: 0.001} + deviceTags: [goniometer, smargon, simulation] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgy: + description: SmarGon SHY (sim) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: {scs_axis: SHY, simulation: true, gradual_motion: true, limits: [-2, 2], tolerance: 0.001} + deviceTags: [goniometer, smargon, simulation] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgz: + description: SmarGon SHZ (sim) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: {scs_axis: SHZ, simulation: true, gradual_motion: true, limits: [10, 22], tolerance: 0.001} + deviceTags: [goniometer, smargon, simulation] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgchi: + description: SmarGon CHI (sim) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: {scs_axis: CHI, simulation: true, gradual_motion: true, limits: [0, 65], tolerance: 0.01} + deviceTags: [goniometer, smargon, simulation] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgphi: + description: SmarGon PHI (sim) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: {scs_axis: PHI, simulation: true, gradual_motion: true, limits: [-180, 180], tolerance: 0.01} + deviceTags: [goniometer, smargon, simulation] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgomega: + description: SmarGon OMEGA (sim) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: {scs_axis: OMEGA, simulation: true, gradual_motion: true, tolerance: 0.01} + deviceTags: [goniometer, smargon, simulation] + enabled: true + onFailure: retry + readoutPriority: baseline + +sim_mon: + description: Simulated 1D monitor (readout for test scans over SmarGon axes) + deviceClass: ophyd_devices.SimMonitor + deviceConfig: + sim_init: + model: "GaussianModel" + deviceTags: [monitor, simulation] + enabled: true + onFailure: retry + readoutPriority: monitored + softwareTrigger: false diff --git a/csaxs_bec/devices/smargon/README.md b/csaxs_bec/devices/smargon/README.md index 53e02a46..53a74a2b 100644 --- a/csaxs_bec/devices/smargon/README.md +++ b/csaxs_bec/devices/smargon/README.md @@ -1,37 +1,50 @@ # SmarGon goniometer (BEC integration via smargopolo) -BEC ophyd device for the [SmarGon](https://www.smaract.com/en/smargon) goniometer. +BEC integration for the [SmarGon](https://www.smaract.com/en/smargon) goniometer. It controls the **virtual SmarGon Coordinate System (SCS) axes** through the [smargopolo](http://smargopolo:3000) RESTful API. smargopolo runs the kinematics and drives the underlying `q1..q6` SmarAct MCS2 stages; this device never commands those directly (that is the separate low-level `csaxs_bec.devices.smaract` device). -## Layout +## Architecture (per-axis, like the `smaract` device) + +Each SCS axis is its **own top-level BEC motor** sharing a **singleton +`SmargopoloController`** (one per `host:port`), plus a thin `SmarGon` **coordinator** +for whole-goniometer operations. This makes `dev.wm`, `umv`, limits and the GUI motor +widgets work natively per axis. ``` smargon/ transport/ # ophyd-free control plane (unit-testable, no BEC needed) - base.py # SmargopoloTransport ABC + Axis/Mode enums + AXIS_INFO - rest.py # RestTransport -- real urllib client against :3000 - fake.py # FakeTransport -- in-memory sim (offline mode + tests) + base.py # SmargopoloTransport ABC + Axis/BeamlineAxis/Mode + AXIS_INFO + rest.py # RestTransport -- real urllib client against :3000 + fake.py # FakeTransport -- in-memory sim (offline mode + tests) errors.py # typed exceptions - positioner.py # SmargonAxis(Device, PositionerBase) -- one SCS axis - smargon.py # SmarGon(PSIDeviceBase) -- parent: transport, axes, mode + controller.py # SmargopoloController(OphydObject) -- singleton per host:port; + # owns transport + lock + mode; referencing / move_scs / nudge / q1..q6 + positioner.py # SmargopoloMotor(Device, PositionerBase) -- one SCS axis, top-level motor + smargon.py # SmarGon(PSIDeviceBase) -- thin coordinator (dev.smargon), delegates ``` -The transport mirrors the Canon CR-N300 device's structure (HTTP transport + an -in-memory fake + threaded polling positioners), since both wrap a non-EPICS, -poll-to-confirm HTTP backend. +The `transport/` layer is shared with (and unchanged from) the original design and is +structured like the Canon CR-N300 device (HTTP transport + in-memory fake + threaded +poll-to-tolerance moves), since both wrap a non-EPICS, poll-to-confirm HTTP backend. -## Axes (v1) +## Devices & naming -Movable positioners: `shx shy shz` (mm) and `chi phi` (deg). `omega` (deg) is -**optional hardware** — enable it per deployment with `has_omega: true`; otherwise -it is `Kind.omitted` and refuses to move. The O-vector (`OX/OY/OZ`) and beamline -nudges (`BX/BY/BZ`) are reachable through the transport but not exposed as v1 -positioners; `nudge_beamline()` provides the BCS nudge path. +- **One motor per axis**, top-level: the YAML key is the BEC name (**you choose it**: + `sgx`, `sgchi`, …); a `scs_axis:` field binds it to the fixed smargopolo axis name + (`SHX/SHY/SHZ/CHI/PHI/OMEGA`). OMEGA is optional hardware — add a motor entry only on + builds that have it. +- **`dev.smargon`** — the coordinator: `reference()`, `get_mode()`, `is_ready()`, + `wait_until_ready()`, `move_scs(...)`, `nudge_beamline(...)`, `motor_positions()`, + `stop_all()`, `transport_info()`, plus read-only `mode`/`rosout` signals. +- The same whole-goniometer ops are reachable from any axis as + `dev..controller.()` (the motors expose `USER_ACCESS = ["controller"]`). -Raw `q1..q6` motor positions are read-only diagnostics via `motor_positions()`. +Because the motors expose `low_limit_travel`/`high_limit_travel` signals and alias +their readback to the device name, `dev.wm(dev.sgchi)` shows real readback/limits +(`low == high` means unbounded, EpicsMotor convention). ## Referencing (read this before moving) @@ -39,33 +52,46 @@ smargopolo only follows targets in `Mode.READY`. **Referencing physically moves goniometer**, so it is a deliberate operator action — never automatic: ```python -smargon.get_mode() # 'UNINITIALIZED' on a fresh server -smargon.reference() # PUT /mode?mode=1, waits until READY (moves hardware!) -smargon.is_ready() # True -smargon.shx.move(0.5) # now honoured +dev.smargon.get_mode() # 'UNINITIALIZED' on a fresh server +dev.smargon.reference() # PUT /mode?mode=1, waits until READY (moves hardware!) +umv(dev.sgx, 0.5) # now honoured ``` Any axis move while not READY raises `SmargopoloModeError` with a "reference first" -message rather than silently doing nothing. `mode` and `rosout` signals surface the -state machine and last ROS message (error detail) for diagnostics. +message. `dev.smargon.mode` / `.rosout` surface the state machine and last ROS message. ## Usage ```python -smargon.shx.move(0.5) # single-axis move (mm) -smargon.move_scs(SHX=0.1, SHZ=18) # coordinated move in one /targetSCS call -smargon.nudge_beamline(BX=0.01) # beamline-frame nudge (/nudgeBCS) -smargon.stop_all() # halt all enabled axes -smargon.transport_info() # connection / identity diagnostics +umv(dev.sgx, 0.5) # single-axis move (mm) +dev.wm('sg*') # readback/setpoint/limits table +dev.smargon.move_scs(SHX=0.1, SHZ=18) # coordinated move in one /targetSCS call +dev.smargon.nudge_beamline(BX=0.01) # beamline-frame nudge (/nudgeBCS) +scans.grid_scan(dev.sgx, -1, 1, 11, dev.sgchi, 0, 60, 7, exp_time=0.1, relative=False) ``` ## Configuration -See `csaxs_bec/device_configs/smargon.yaml` for real + simulation entries. -Key `deviceConfig` keys: `host`, `port`, `has_omega`, `tolerance` (float or per-axis -map), `limits` (per-axis `[low, high]` soft limits — **verify against the build**), -`poll_interval`, `move_timeout`. Simulation: `simulation: true` (+ optional -`gradual_motion: true`) uses `FakeTransport`, no server required. +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`. +Simulation: `simulation: true` (+ optional `gradual_motion: true`) uses `FakeTransport`, +no server required. + +## Limits & coupled-axis faults + +SCS soft limits are **user-set** (per experiment), not hardware ranges: a SmarGon's +true per-axis travel is coupled across all axes (like a hexapod) and can't be reliably +declared in advance. smargopolo owns the real limits. Both failure modes are handled: + +- **Rejected up front** — the PUT returns an error and the move status fails immediately. +- **Faulted mid-move** — if a target is accepted (`202`) but the controller goes to + `Mode.ERROR` after partial motion, the move waiters detect it on the next poll and + fail the status with the `rosout` detail (see `SmargopoloController.raise_if_error`). + +Move completion is by **readback tolerance** (user-set; done on the first in-tolerance +sample, so active position-hold dithering doesn't block completion). ## Tests @@ -75,23 +101,6 @@ pytest tests/tests_devices/test_smargon_transport.py tests/tests_devices/test_sm All run against `FakeTransport` — no hardware or server needed. -## Limits & coupled-axis faults - -SCS soft limits are **user-set** (per experiment), not hardware ranges: a SmarGon's -true per-axis travel is coupled across all SCS axes (like a hexapod) and can't be -reliably declared in advance. smargopolo owns the real limits. The device handles -both failure modes: - -- **Rejected up front** — if smargopolo refuses an out-of-range target, the PUT - returns an error and the move status fails immediately. -- **Faulted mid-move** — if a target is accepted (`202`) but the controller goes to - `Mode.ERROR` only after partial motion, the move waiters detect it on the next - poll and fail the status with the controller's `rosout` detail (rather than - running to the move timeout). See `SmarGon._raise_if_error`. - -Move completion is by **readback tolerance** (user-set; done on the first in-tolerance -sample, so active position-hold dithering doesn't block completion). - ## Open items / to confirm with the smargopolo maintainer (Wayne Glettig) - **Stop semantics**: no explicit stop endpoint; we retarget axes to their readback @@ -99,5 +108,6 @@ sample, so active position-hold dithering doesn't block completion). - **Mid-move fault signal**: we infer faults from `Mode.ERROR`. Confirm an out-of-range / coupled-limit violation reliably drives `/mode` to 99 (and that `rosout` carries the reason), so the abort path is dependable. -- *(resolved)* OMEGA is drivable via `/targetSCS` wherever it physically exists — - handled by `has_omega`. +- **PHI wrap**: the MX-lab reference client wraps PHI to `[-180,180)` before sending. + Confirm whether smargopolo wraps server-side, or whether the motor should. +- *(resolved)* OMEGA is drivable via `/targetSCS` wherever it physically exists. diff --git a/csaxs_bec/devices/smargon/__init__.py b/csaxs_bec/devices/smargon/__init__.py index e869ba65..da631e22 100644 --- a/csaxs_bec/devices/smargon/__init__.py +++ b/csaxs_bec/devices/smargon/__init__.py @@ -1,13 +1,17 @@ """SmarGon goniometer integration for BEC (via the smargopolo REST API). -Public entry point is :class:`SmarGon`. The control transport -(:mod:`.transport`) is ophyd-free and independently importable, so the REST client -and its in-memory fake can be used and tested without the BEC stack. +Per-axis architecture (mirrors the ``smaract`` device): each SCS axis is a top-level +:class:`SmargopoloMotor` sharing a singleton :class:`SmargopoloController` per +smargopolo server; :class:`SmarGon` is a thin coordinator (``dev.smargon``) for +whole-goniometer operations (referencing, mode, coordinated moves, diagnostics). + +The control transport (:mod:`.transport`) is ophyd-free and independently importable. """ from __future__ import annotations -from .positioner import SmargonAxis +from .controller import SmargopoloController +from .positioner import SmargopoloMotor from .smargon import SmarGon from .transport import ( Axis, @@ -21,7 +25,8 @@ from .transport import ( __all__ = [ "SmarGon", - "SmargonAxis", + "SmargopoloController", + "SmargopoloMotor", "Axis", "BeamlineAxis", "Mode", diff --git a/csaxs_bec/devices/smargon/controller.py b/csaxs_bec/devices/smargon/controller.py new file mode 100644 index 00000000..d5a7ca88 --- /dev/null +++ b/csaxs_bec/devices/smargon/controller.py @@ -0,0 +1,284 @@ +"""Shared controller for the SmarGon goniometer (smargopolo backend). + +``SmargopoloController`` is a **singleton per ``(host, port, simulation)``** (the same +pattern as :class:`ophyd_devices.utils.controller.Controller`): every +:class:`~csaxs_bec.devices.smargon.positioner.SmargopoloMotor` and the thin +:class:`~csaxs_bec.devices.smargon.smargon.SmarGon` coordinator that point at the same +smargopolo server share **one** controller instance -- one transport, one lock, one +``/mode`` state machine. + +It owns the whole-goniometer operations that don't belong to a single axis: +referencing / the mode state machine, the coordinated multi-axis move +(``/targetSCS`` in one request), beamline nudges, and the raw ``q1..q6`` diagnostics. +All hardware access goes through the injected +:class:`~csaxs_bec.devices.smargon.transport.base.SmargopoloTransport`, serialised +behind ``self._lock``. +""" + +from __future__ import annotations + +import threading +import time + +from ophyd import OphydObject + +from .transport.base import Axis, BeamlineAxis, Mode, SmargopoloTransport +from .transport.errors import SmargopoloModeError +from .transport.fake import FakeTransport +from .transport.rest import RestTransport + +try: # pragma: no cover + from bec_lib.logger import bec_logger + + logger = bec_logger.logger +except Exception: # pragma: no cover + import logging + + logger = logging.getLogger(__name__) + + +class SmargopoloController(OphydObject): + """One shared control plane for a smargopolo server (singleton per host/port). + + Reachable from any axis as ``dev..controller`` (the motors set + ``USER_ACCESS = ["controller"]``) and mirrored by the ``dev.smargon`` + coordinator. + """ + + _controller_instances: dict[str, "SmargopoloController"] = {} + _initialized = False + + SUB_CONNECTION_CHANGE = "connection_change" + + USER_ACCESS = [ + "reference", + "is_ready", + "wait_until_ready", + "get_mode", + "move_scs", + "nudge_beamline", + "motor_positions", + "stop_all", + "transport_info", + ] + + def __new__(cls, *, host: str = "smargopolo", port: int = 3000, simulation: bool = False, **_): + key = f"{host}:{port}:{'sim' if simulation else 'real'}" + if key not in cls._controller_instances: + cls._controller_instances[key] = object.__new__(cls) + return cls._controller_instances[key] + + def __init__( + self, + *, + host: str = "smargopolo", + port: int = 3000, + simulation: bool = False, + timeout: float = 5.0, + gradual_motion: bool = False, + device_manager=None, + name: str = "smargopolo_controller", + parent=None, + **kwargs, + ) -> None: + if self._initialized: + # Singleton: a later constructor may pass a device_manager the first + # one lacked -- adopt it, but do not rebuild the transport. + if device_manager is not None and getattr(self, "device_manager", None) is None: + self.device_manager = device_manager + return + super().__init__(name=name, parent=parent, **kwargs) + self._lock = threading.RLock() + self._host = host + self._port = port + self._simulation = simulation + self.device_manager = device_manager + if simulation: + self.transport: SmargopoloTransport = FakeTransport(gradual_motion=gradual_motion) + else: + self.transport = RestTransport(host=host, port=port, timeout=timeout) + self._axes: dict[Axis, object] = {} # scs_axis -> SmargopoloMotor (registered by motors) + self._connected = False + self._initialized = True + + # -- axis registry ----------------------------------------------------- + + def register_axis(self, motor) -> None: + """Called by each :class:`SmargopoloMotor` so the controller can find it.""" + self._axes[Axis(motor.scs_axis)] = motor + + # -- 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 + + def close(self) -> None: + with self._lock: + try: + self.transport.close() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"{self.name}: transport close failed: {exc}") + finally: + self._connected = False + + @property + def connected(self) -> bool: + return self._connected + + # -- locked transport passthroughs ------------------------------------- + + def get_position(self, axis: Axis) -> float: + with self._lock: + return self.transport.get_position(Axis(axis)) + + def get_positions(self) -> dict: + with self._lock: + return self.transport.get_positions() + + def move_absolute(self, targets: dict) -> None: + with self._lock: + self.transport.move_absolute({Axis(a): float(v) for a, v in targets.items()}) + + def move_relative(self, deltas: dict) -> None: + with self._lock: + self.transport.move_relative({Axis(a): float(v) for a, v in deltas.items()}) + + def stop(self, axes: list | None = None) -> None: + with self._lock: + self.transport.stop([Axis(a) for a in axes] if axes is not None else None) + + def get_info(self): + with self._lock: + return self.transport.get_info() + + def mode(self) -> Mode: + """Current state-machine mode (enum) -- used internally by axes/coordinator.""" + with self._lock: + return self.transport.get_mode() + + # -- state machine / referencing (whole-goniometer ops) ---------------- + + def require_ready(self) -> None: + """Raise :class:`SmargopoloModeError` unless the goniometer is READY.""" + m = self.mode() + if m is not Mode.READY: + raise SmargopoloModeError( + f"{self.name} is in {m.name} (mode {int(m)}); call reference() and wait for " + "READY before moving" + ) + + def raise_if_error(self) -> None: + """Raise if smargopolo went to ERROR (used to abort in-flight moves). + + A target accepted with ``202`` can still fault after partial motion when a + coupled hardware limit is hit; move waiters poll this so the move fails with + the controller's ``rosout`` detail instead of running to the move timeout. + """ + info = self.get_info() + if info.mode is Mode.ERROR: + raise SmargopoloModeError( + f"{self.name}: smargopolo entered ERROR during motion " + f"(likely a hardware/coupled-axis limit): {info.rosout or 'no detail'}" + ) + + def reference(self, wait: bool = True, timeout: float = 120.0) -> str: + """Reference the goniometer (``REFERENCING`` -> ``READY``). + + WARNING: this physically moves the goniometer to find its reference marks. + Deliberate operator action. Returns the resulting mode name. + """ + with self._lock: + self.transport.set_mode(Mode.REFERENCING) + logger.info(f"{self.name}: referencing started") + if wait: + m = self.wait_until_ready(timeout=timeout) + logger.info(f"{self.name}: referencing finished -> {m.name}") + return m.name + return self.mode().name + + def is_ready(self) -> bool: + return self.mode() is Mode.READY + + def wait_until_ready(self, timeout: float = 120.0, poll: float = 0.5) -> Mode: + deadline = time.monotonic() + timeout + while True: + m = self.mode() + if m is Mode.READY: + return m + if m is Mode.ERROR: + info = self.get_info() + raise SmargopoloModeError(f"{self.name}: smargopolo in ERROR: {info.rosout}") + if time.monotonic() > deadline: + raise TimeoutError(f"{self.name}: not READY after {timeout}s (mode {m.name})") + time.sleep(poll) + + def get_mode(self) -> str: + """State-machine mode name, for the user console.""" + return self.mode().name + + # -- coordinated / diagnostic ops -------------------------------------- + + def move_scs(self, wait: bool = True, **targets: float) -> None: + """Move several SCS axes at once in a single ``/targetSCS`` request. + + Keys are SCS axis names, e.g. ``move_scs(SHX=0.1, SHZ=18)``. Each targeted + axis is limit-checked against its registered motor (if any) before sending. + """ + self.require_ready() + parsed = {Axis(k): float(v) for k, v in targets.items()} + for ax, val in parsed.items(): + motor = self._axes.get(ax) + if motor is not None: + motor.check_value(val) + self.move_absolute(parsed) + if wait: + for ax, val in parsed.items(): + self._wait_axis(ax, val) + + def _wait_axis(self, axis: Axis, target: float, timeout: float | None = None) -> None: + motor = self._axes.get(axis) + tol = getattr(motor, "_tolerance", 1e-3) + poll = getattr(motor, "_poll_interval", 0.1) + deadline = time.monotonic() + (timeout or getattr(motor, "_move_timeout", 60.0)) + while True: + pos = self.get_position(axis) + if motor is not None: + motor._update_readback(pos) # noqa: SLF001 + if abs(pos - target) <= tol: + return + self.raise_if_error() + if time.monotonic() > deadline: + raise TimeoutError(f"{axis.value}: move to {target} timed out") + time.sleep(poll) + + def nudge_beamline(self, modify_offset: bool = False, **deltas: float) -> None: + """Nudge the sample along beamline axes (``BX``/``BY``/``BZ``).""" + self.require_ready() + parsed = {BeamlineAxis(k): float(v) for k, v in deltas.items()} + with self._lock: + self.transport.nudge_beamline(parsed, modify_offset=modify_offset) + + def stop_all(self) -> None: + """Halt all SCS axes.""" + self.stop(None) + + def motor_positions(self) -> dict: + """Raw ``q1..q6`` motor positions (read-only diagnostics).""" + return self.get_info().motor_positions + + def transport_info(self) -> dict: + info = self.get_info() if self.connected else None + return { + "connected": self.connected, + "simulation": self._simulation, + "host": self._host, + "port": self._port, + "mode": info.mode.name if info else None, + "serial_number": getattr(info, "serial_number", None), + "seq": getattr(info, "seq", None), + "axes": sorted(a.value for a in self._axes), + } diff --git a/csaxs_bec/devices/smargon/positioner.py b/csaxs_bec/devices/smargon/positioner.py index 18a03279..032e1079 100644 --- a/csaxs_bec/devices/smargon/positioner.py +++ b/csaxs_bec/devices/smargon/positioner.py @@ -1,16 +1,20 @@ -"""Transport-backed positioner for a single SmarGon SCS axis. +"""Per-axis SmarGon motor -- a top-level BEC device for one SCS axis. -``SmargonAxis`` is a real ophyd positioner (``Device`` + ``PositionerBase``) so BEC -treats each SCS axis (SHX/SHY/SHZ/CHI/PHI/OMEGA) as a motor: ``move()``/``set()`` -return a ``DeviceStatus`` that completes when the axis reaches target (or on -timeout/stop), plus ``stop()``. It follows the threaded-move pattern of the Canon -``CanonAxis`` but reads/writes through the parent device's injected -:class:`~csaxs_bec.devices.smargon.transport.base.SmargopoloTransport`. +``SmargopoloMotor`` is an independent ophyd positioner (``Device`` + +``PositionerBase``): each SCS axis (SHX/SHY/SHZ/CHI/PHI/OMEGA) is its own top-level +BEC motor, exactly like the ``smaract`` motors. It talks to hardware only through a +shared :class:`~csaxs_bec.devices.smargon.controller.SmargopoloController` (singleton +per smargopolo server), so ``dev.sgx`` and ``dev.sgchi`` pointing at the same host +share one transport, one lock and one mode state machine. -Because smargopolo runs the kinematics, each axis simply PUTs its own SCS target -and polls its own SCS readback; the coupling to the q1..q6 motors is the server's -job, not ours. The axis never opens its own connection: it borrows the parent's -transport and a shared lock so concurrent axis commands serialise onto one session. +Two conventions matter for BEC's motor UX to work natively (``dev.wm``, ``umv``, the +GUI motor widgets): + +* the readback signal is aliased to the device name, so ``read()`` yields a key equal + to the device name (what ``dev.wm`` looks up), and +* soft limits are published via ``low_limit_travel`` / ``high_limit_travel`` signals, + which the device server broadcasts as the device's limits (``low == high`` means + unbounded, following the EpicsMotor convention). """ from __future__ import annotations @@ -23,88 +27,97 @@ from ophyd import Component as Cpt from ophyd import Device, DeviceStatus, Kind, PositionerBase, Signal from ophyd.utils import LimitError -from .transport.base import Axis, SmargopoloTransport +from .controller import SmargopoloController +from .transport.base import AXIS_INFO, Axis -class SmargonAxis(Device, PositionerBase): - """One SCS axis of the SmarGon goniometer. +class SmargopoloMotor(Device, PositionerBase): + """One SCS axis of a SmarGon goniometer, as a standalone BEC motor. Parameters ---------- - axis: - Which :class:`Axis` this positioner drives. - egu: - Engineering units for the readback (``"mm"`` or ``"deg"``). + scs_axis: + Which smargopolo SCS axis this motor drives (``"SHX"``, ``"CHI"``, ...). + host / port / simulation / timeout / gradual_motion: + Select/parameterise the shared :class:`SmargopoloController`. limits: - Optional soft ``(low, high)`` limits. ``None`` (default) means unbounded - -- the SCS travel ranges are build-specific and should be set in config. - tolerance: - Absolute readback tolerance for declaring a move complete. - poll_interval: - Seconds between readback polls while moving. - move_timeout: - Seconds before a move fails with ``TimeoutError``. + Optional ``(low, high)`` soft limits in SCS units. ``low == high`` (the + default ``(0, 0)``) means unbounded -- the controller still enforces the + real coupled hardware limits. + tolerance / poll_interval / move_timeout: + Move-completion tolerance and polling cadence / timeout. + egu: + Engineering unit; defaults to the axis' natural unit (mm or deg). """ readback = Cpt(Signal, value=0.0, kind=Kind.hinted) setpoint = Cpt(Signal, value=0.0, kind=Kind.normal) motor_is_moving = Cpt(Signal, value=0, kind=Kind.normal) + low_limit_travel = Cpt(Signal, value=0.0, kind=Kind.config) + high_limit_travel = Cpt(Signal, value=0.0, kind=Kind.config) SUB_READBACK = "readback" _default_sub = SUB_READBACK + USER_ACCESS = ["controller"] + def __init__( self, prefix: str = "", *, name: str, - axis: Axis, - egu: str = "", - limits: tuple[float, float] | None = None, + scs_axis: str, + host: str = "smargopolo", + port: int = 3000, + simulation: bool = False, + timeout: float = 5.0, + gradual_motion: bool = False, + limits: tuple[float, float] | list | None = None, tolerance: float = 0.001, poll_interval: float = 0.1, move_timeout: float = 60.0, - parent=None, + egu: str | None = None, + device_manager=None, kind=None, + parent=None, **kwargs, ) -> None: - self._axis = Axis(axis) - self._egu = egu + self.scs_axis = Axis(scs_axis) self._tolerance = tolerance self._poll_interval = poll_interval self._move_timeout = move_timeout - self._limits = tuple(limits) if limits is not None else None + self._egu = egu if egu is not None else AXIS_INFO[self.scs_axis].egu self._stopped = False self._move_thread: threading.Thread | None = None - self._fallback_lock = threading.RLock() - # Optional gate set by the parent (e.g. refuse to move unless READY). + # Optional extra gate (parent/tests). The controller READY-gate is always applied. self.pre_move_hook: Callable[[float], None] | None = None + # Shared, singleton controller for this smargopolo server. + self.controller = SmargopoloController( + host=host, + port=port, + simulation=simulation, + timeout=timeout, + gradual_motion=gradual_motion, + device_manager=device_manager, + ) + super().__init__(prefix, name=name, parent=parent, kind=kind, **kwargs) - # -- transport access -------------------------------------------------- + # Alias readback to the device name so dev.wm() / BEC find it under `name`. + self.readback.name = self.name - @property - def _transport(self) -> SmargopoloTransport: - transport = getattr(self.parent, "transport", None) - if transport is None: - raise RuntimeError(f"{self.name}: parent has no transport") - return transport + if limits is not None: + self.low_limit_travel.put(float(limits[0])) + self.high_limit_travel.put(float(limits[1])) - @property - def _lock(self) -> threading.RLock: - return getattr(self.parent, "_transport_lock", None) or self._fallback_lock - - def _call(self, fn): - """Run a transport call under the shared lock.""" - with self._lock: - return fn() + self.controller.register_axis(self) # -- positioner interface ---------------------------------------------- @property def limits(self) -> tuple[float, float]: - return self._limits if self._limits is not None else (0.0, 0.0) + return (self.low_limit_travel.get(), self.high_limit_travel.get()) @property def low_limit(self) -> float: @@ -123,11 +136,22 @@ class SmargonAxis(Device, PositionerBase): return self.readback.get() def check_value(self, value: float) -> None: - if self._limits is None: - return - low, high = self._limits + low, high = self.limits if low != high and not low <= value <= high: - raise LimitError(f"{self.name}: position {value} outside limits {self._limits}") + raise LimitError(f"{self.name}: position {value} outside limits {(low, high)}") + + # -- connection lifecycle ---------------------------------------------- + + # pylint: disable=arguments-differ + def wait_for_connection(self, all_signals: bool = False, timeout: float = 10.0) -> None: + self.controller.connect(timeout=timeout) + try: + self._update_readback(self.controller.get_position(self.scs_axis)) + except Exception as exc: # pylint: disable=broad-except + self.log.warning("%s: initial readback failed: %s", self.name, exc) + super().wait_for_connection(all_signals=all_signals, timeout=timeout) + + # -- motion ------------------------------------------------------------ def _update_readback(self, value: float) -> None: old = self.readback.get() @@ -137,35 +161,21 @@ class SmargonAxis(Device, PositionerBase): sub_type=self.SUB_READBACK, old_value=old, value=value, timestamp=time.time() ) - def _check_controller_error(self) -> None: - """Raise if the parent reports smargopolo went to ERROR mid-move. - - Coupled hardware limits (the underlying stages' true ranges depend on all - SCS axes and are not reliably known to BEC) mean a target accepted with a - ``202`` can still fault *after* partial motion -- like a hexapod hitting a - joint limit. The parent installs this so the move fails loudly with the - controller's ``rosout`` detail rather than silently timing out. - """ - check = getattr(self.parent, "_raise_if_error", None) - if check is not None: - self._call(check) - def _poll_until_target(self, target: float, status: DeviceStatus) -> None: deadline = time.monotonic() + self._move_timeout try: while not self._stopped: - pos = self._call(lambda: self._transport.get_position(self._axis)) + pos = self.controller.get_position(self.scs_axis) self._update_readback(pos) if abs(pos - target) <= self._tolerance: break - self._check_controller_error() + self.controller.raise_if_error() if time.monotonic() > deadline: raise TimeoutError( f"{self.name}: move to {target} timed out after {self._move_timeout}s" ) time.sleep(self._poll_interval) - pos = self._call(lambda: self._transport.get_position(self._axis)) - self._update_readback(pos) + self._update_readback(self.controller.get_position(self.scs_axis)) except Exception as exc: # pylint: disable=broad-except self.motor_is_moving.put(0) if not status.done: @@ -180,8 +190,9 @@ class SmargonAxis(Device, PositionerBase): status.set_finished() def move(self, position: float, wait: bool = True, timeout=None, moved_cb=None) -> DeviceStatus: - """Move the axis to *position*; return a status that completes at target.""" + """Move to *position*; return a status that completes at target.""" self.check_value(position) + self.controller.require_ready() if self.pre_move_hook is not None: self.pre_move_hook(position) self._stopped = False @@ -193,7 +204,7 @@ class SmargonAxis(Device, PositionerBase): self.motor_is_moving.put(1) try: - self._call(lambda: self._transport.move_absolute({self._axis: position})) + self.controller.move_absolute({self.scs_axis: position}) except Exception as exc: # pylint: disable=broad-except self.motor_is_moving.put(0) status.set_exception(exc) @@ -216,7 +227,7 @@ class SmargonAxis(Device, PositionerBase): """Halt this axis immediately (best-effort: never raises during teardown).""" self._stopped = True try: - self._call(lambda: self._transport.stop([self._axis])) + self.controller.stop([self.scs_axis]) except Exception as exc: # pylint: disable=broad-except self.log.warning("%s: transport stop failed: %s", self.name, exc) finally: diff --git a/csaxs_bec/devices/smargon/smargon.py b/csaxs_bec/devices/smargon/smargon.py index b5d2ee77..528a26b3 100644 --- a/csaxs_bec/devices/smargon/smargon.py +++ b/csaxs_bec/devices/smargon/smargon.py @@ -1,30 +1,20 @@ -"""SmarGon goniometer -- BEC ophyd device (via the smargopolo REST API). +"""SmarGon coordinator -- a thin top-level device for whole-goniometer operations. -This device controls the *virtual* SmarGon Coordinate System (SCS) axes. The -smargopolo server runs the kinematics and drives the underlying q1..q6 stages; we -never command those directly (that is the separate low-level ``smaract`` device). +In the per-axis architecture the SCS axes are independent top-level +:class:`~csaxs_bec.devices.smargon.positioner.SmargopoloMotor` devices +(``dev.sgx``, ``dev.sgchi``, ...). ``SmarGon`` is a small companion device +(``dev.smargon``) that exposes the operations that belong to the *whole* goniometer +rather than one axis -- referencing / the mode state machine, the coordinated +multi-axis move, beamline nudges and the raw ``q1..q6`` diagnostics. -Architecture ------------- -* ``self.transport`` -- a :class:`SmargopoloTransport` (real :class:`RestTransport` - or, when ``simulation: true``, :class:`FakeTransport`). All control goes through - it, serialised behind ``self._transport_lock``. -* ``shx/shy/shz/chi/phi`` (+ optional ``omega``) -- :class:`SmargonAxis` - positioners sharing the transport; they look like motors to BEC. -* ``mode`` / ``rosout`` -- read-only signals proxying the state machine + last ROS - message for diagnostics. - -State machine / referencing ---------------------------- -smargopolo only follows targets in ``Mode.READY``. Referencing physically moves -the goniometer, so it is a **deliberate operator action** here -- call -:meth:`reference`. Axes refuse to move unless READY (a pre-move guard surfaces a -clear "reference first" error instead of silently doing nothing). +It owns no axes and no motion logic of its own: it holds a reference to the same +shared :class:`~csaxs_bec.devices.smargon.controller.SmargopoloController` the motors +use (singleton per smargopolo server) and delegates to it. The same operations are +also reachable from any axis as ``dev..controller.()``. """ from __future__ import annotations -import threading import time from typing import TYPE_CHECKING @@ -33,11 +23,8 @@ from ophyd import Kind, Signal from ophyd.utils import ReadOnlyError from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase -from .positioner import SmargonAxis -from .transport.base import Axis, BeamlineAxis, Mode, SmargopoloTransport -from .transport.errors import SmargopoloModeError -from .transport.fake import FakeTransport -from .transport.rest import RestTransport +from .controller import SmargopoloController +from .transport.base import Mode if TYPE_CHECKING: # pragma: no cover from bec_lib.devicemanager import ScanInfo @@ -52,11 +39,6 @@ except Exception: # pragma: no cover logger = logging.getLogger(__name__) -# Axes exposed as movable positioners in v1. OMEGA is appended at runtime when the -# deployment has the rotation drive (``has_omega: true``). -_V1_AXES: tuple[Axis, ...] = (Axis.SHX, Axis.SHY, Axis.SHZ, Axis.CHI, Axis.PHI) - - class SmargopoloModeSignal(Signal): """Read-only signal exposing the smargopolo state-machine mode (as an int).""" @@ -66,7 +48,7 @@ class SmargopoloModeSignal(Signal): # pylint: disable=arguments-differ def get(self, **kwargs): - value = int(self.parent._get_mode()) # noqa: SLF001 + value = int(self.parent.controller.mode()) self._readback = value return value @@ -87,7 +69,7 @@ class SmargopoloRosoutSignal(Signal): # pylint: disable=arguments-differ def get(self, **kwargs): - value = self.parent._get_info().rosout # noqa: SLF001 + value = self.parent.controller.get_info().rosout self._readback = value return value @@ -100,9 +82,10 @@ class SmargopoloRosoutSignal(Signal): class SmarGon(PSIDeviceBase): - """BEC ophyd device for the SmarGon goniometer driven through smargopolo. + """Thin coordinator for the whole SmarGon goniometer (``dev.smargon``). - See the module docstring for architecture and the referencing contract. + Delegates every operation to the shared :class:`SmargopoloController`; holds no + axes. Point it at the same ``host``/``port`` as the axis motors. """ USER_ACCESS = [ @@ -110,23 +93,14 @@ class SmarGon(PSIDeviceBase): "is_ready", "wait_until_ready", "get_mode", - "stop_all", - "motor_positions", "move_scs", "nudge_beamline", + "motor_positions", + "stop_all", "transport_info", + "controller", ] - # -- motion axes (look like motors to BEC) ----------------------------- - shx = Cpt(SmargonAxis, axis=Axis.SHX, egu="mm", kind=Kind.normal) - shy = Cpt(SmargonAxis, axis=Axis.SHY, egu="mm", kind=Kind.normal) - shz = Cpt(SmargonAxis, axis=Axis.SHZ, egu="mm", kind=Kind.normal) - chi = Cpt(SmargonAxis, axis=Axis.CHI, egu="deg", kind=Kind.normal) - phi = Cpt(SmargonAxis, axis=Axis.PHI, egu="deg", kind=Kind.normal) - # Optional rotation drive -- omitted unless ``has_omega`` is set (see __init__). - omega = Cpt(SmargonAxis, axis=Axis.OMEGA, egu="deg", kind=Kind.normal) - - # -- diagnostics ------------------------------------------------------- mode = Cpt(SmargopoloModeSignal, value=int(Mode.UNINITIALIZED), kind=Kind.normal) rosout = Cpt(SmargopoloRosoutSignal, value="", kind=Kind.config) @@ -138,131 +112,28 @@ class SmarGon(PSIDeviceBase): port: int = 3000, simulation: bool = False, timeout: float = 5.0, - has_omega: bool = False, - limits: dict | None = None, - tolerance: float | dict | None = None, - poll_interval: float = 0.1, - move_timeout: float = 60.0, gradual_motion: bool = False, scan_info: "ScanInfo | None" = None, device_manager=None, **kwargs, ) -> None: - """See class/module docstring for the architecture. - - Args: - host/port: smargopolo server address (ignored when ``simulation``). - simulation: use :class:`FakeTransport` -- no server needed. - timeout: per-request transport timeout in seconds. - has_omega: enable the OMEGA rotation axis (build-dependent). - limits: optional ``{axis_name: [low, high]}`` soft limits (SCS units). - tolerance: per-move completion tolerance; a float (all axes) or a - ``{axis_name: value}`` mapping. Defaults to 1e-3. - poll_interval/move_timeout: readback polling cadence / move timeout. - gradual_motion: simulate finite-speed travel in the fake transport. - """ - self._simulation = simulation - self._has_omega = has_omega - self._transport_lock = threading.RLock() - - # Build the transport BEFORE super().__init__ so axis components can reach it. - if simulation: - self.transport: SmargopoloTransport = FakeTransport(gradual_motion=gradual_motion) - else: - self.transport = RestTransport(host=host, port=port, timeout=timeout) - + self.controller = SmargopoloController( + host=host, + port=port, + simulation=simulation, + timeout=timeout, + gradual_motion=gradual_motion, + device_manager=device_manager, + ) super().__init__(name=name, scan_info=scan_info, device_manager=device_manager, **kwargs) - # Configure the enabled axes; disable OMEGA unless the build has it. - enabled = set(_V1_AXES) | ({Axis.OMEGA} if has_omega else set()) - limits = limits or {} - for ax in self._all_axes(): - if ax._axis not in enabled: # noqa: SLF001 - ax.kind = Kind.omitted - ax.pre_move_hook = self._make_disabled_hook(ax._axis) # noqa: SLF001 - continue - ax.pre_move_hook = self._ready_guard - ax._poll_interval = poll_interval # noqa: SLF001 - ax._move_timeout = move_timeout # noqa: SLF001 - lim = limits.get(ax._axis.value) # noqa: SLF001 - if lim is not None: - ax._limits = (float(lim[0]), float(lim[1])) # noqa: SLF001 - tol = self._axis_tolerance(tolerance, ax._axis) # noqa: SLF001 - if tol is not None: - ax._tolerance = float(tol) # noqa: SLF001 - - # -- axis helpers ------------------------------------------------------ - - def _all_axes(self) -> tuple[SmargonAxis, ...]: - return (self.shx, self.shy, self.shz, self.chi, self.phi, self.omega) - - def _enabled_axes(self) -> tuple[SmargonAxis, ...]: - return tuple(ax for ax in self._all_axes() if ax.kind != Kind.omitted) - - @staticmethod - def _axis_tolerance(tolerance, axis: Axis): - if tolerance is None: - return None - if isinstance(tolerance, dict): - return tolerance.get(axis.value) - return tolerance - - @staticmethod - def _make_disabled_hook(axis: Axis): - def _hook(_position: float) -> None: - raise SmargopoloModeError( - f"axis {axis.value} is not enabled on this SmarGon " - "(set has_omega: true in the device config to enable OMEGA)" - ) - - return _hook - - def _ready_guard(self, _position: float) -> None: - mode = self._get_mode() - if mode is not Mode.READY: - raise SmargopoloModeError( - f"{self.name} is in {mode.name} (mode {int(mode)}); call reference() and wait " - "for READY before moving" - ) - - # -- transport routing ------------------------------------------------- - - def _get_mode(self) -> Mode: - with self._transport_lock: - return self.transport.get_mode() - - def _get_info(self): - with self._transport_lock: - return self.transport.get_info() - - def _raise_if_error(self) -> None: - """Raise if smargopolo is in ERROR (used to abort in-flight moves). - - smargopolo accepts a target with ``202`` even when the coupled hardware - limits will be violated, then faults once motion hits the real boundary. - Move waiters poll this so a mid-move fault surfaces the controller's - ``rosout`` message instead of running until the move timeout. - """ - info = self._get_info() - if info.mode is Mode.ERROR: - raise SmargopoloModeError( - f"{self.name}: smargopolo entered ERROR during motion " - f"(likely a hardware/coupled-axis limit): {info.rosout or 'no detail'}" - ) - # -- connection lifecycle ---------------------------------------------- # pylint: disable=arguments-differ def wait_for_connection(self, all_signals: bool = False, timeout: float = 10.0) -> None: - """Open the transport session, then wait for signals.""" - with self._transport_lock: - self.transport.connect(timeout=timeout) - super().wait_for_connection(all_signals=all_signals, timeout=timeout) - - def on_connected(self) -> None: - """Log identity and seed enabled-axis readbacks.""" + self.controller.connect(timeout=timeout) try: - info = self._get_info() + info = self.controller.get_info() logger.info( f"{self.name} connected to smargopolo (S/N {info.serial_number!r}, " f"mode {info.mode.name})" @@ -273,125 +144,39 @@ class SmarGon(PSIDeviceBase): ) except Exception as exc: # pylint: disable=broad-except logger.warning(f"{self.name}: could not read smargopolo info: {exc}") - for ax in self._enabled_axes(): - try: - with self._transport_lock: - ax._update_readback(self.transport.get_position(ax._axis)) # noqa: SLF001 - except Exception as exc: # pylint: disable=broad-except - logger.warning(f"{self.name}: initial readback for {ax.name} failed: {exc}") + super().wait_for_connection(all_signals=all_signals, timeout=timeout) def on_stop(self) -> None: - """Halt all enabled axes.""" - self.stop_all() + """Halt all SCS axes.""" + self.controller.stop_all() - def on_destroy(self) -> None: - """Close the transport session.""" - try: - self.transport.close() - except Exception as exc: # pylint: disable=broad-except - logger.warning(f"{self.name}: transport close failed: {exc}") + # -- delegated whole-goniometer operations ----------------------------- - # -- control convenience (USER_ACCESS) --------------------------------- - - def reference(self, wait: bool = True, timeout: float = 120.0) -> Mode: - """Reference the goniometer (``Mode.REFERENCING`` -> ``READY``). - - WARNING: this physically moves the goniometer to find its reference marks. - It is a deliberate operator action. Returns the resulting mode. - """ - with self._transport_lock: - self.transport.set_mode(Mode.REFERENCING) - logger.info(f"{self.name}: referencing started") - if wait: - mode = self.wait_until_ready(timeout=timeout) - logger.info(f"{self.name}: referencing finished -> {mode.name}") - return mode - return self._get_mode() + def reference(self, wait: bool = True, timeout: float = 120.0) -> str: + """Reference the goniometer (READY). WARNING: physically moves it.""" + return self.controller.reference(wait=wait, timeout=timeout) def is_ready(self) -> bool: - """Whether smargopolo is in ``Mode.READY`` (follow-target).""" - return self._get_mode() is Mode.READY + return self.controller.is_ready() - def wait_until_ready(self, timeout: float = 120.0, poll: float = 0.5) -> Mode: - """Block until the mode is READY (or ERROR), or *timeout* elapses.""" - deadline = time.monotonic() + timeout - while True: - mode = self._get_mode() - if mode is Mode.READY: - return mode - if mode is Mode.ERROR: - info = self._get_info() - raise SmargopoloModeError(f"{self.name}: smargopolo in ERROR: {info.rosout}") - if time.monotonic() > deadline: - raise TimeoutError(f"{self.name}: not READY after {timeout}s (mode {mode.name})") - time.sleep(poll) + def wait_until_ready(self, timeout: float = 120.0, poll: float = 0.5) -> str: + return self.controller.wait_until_ready(timeout=timeout, poll=poll).name def get_mode(self) -> str: - """Return the current state-machine mode name (for the user console).""" - return self._get_mode().name - - def stop_all(self) -> None: - """Halt every enabled axis immediately.""" - for ax in self._enabled_axes(): - ax.stop() + return self.controller.get_mode() def move_scs(self, wait: bool = True, **targets: float) -> None: - """Move several SCS axes at once in a single ``/targetSCS`` call. - - More efficient than moving axes one-by-one for coordinated moves, e.g. - ``smargon.move_scs(SHX=0.1, SHZ=18)``. Keys are SCS axis names. - """ - self._ready_guard(0.0) - parsed = {Axis(k): float(v) for k, v in targets.items()} - for ax in parsed: - self._axis_for(ax).check_value(parsed[ax]) - with self._transport_lock: - self.transport.move_absolute(parsed) - if wait: - for ax_enum, val in parsed.items(): - self._wait_axis(self._axis_for(ax_enum), val) - - def _wait_axis(self, axis: SmargonAxis, target: float, timeout: float | None = None) -> None: - deadline = time.monotonic() + (timeout or axis._move_timeout) # noqa: SLF001 - while True: - with self._transport_lock: - pos = self.transport.get_position(axis._axis) # noqa: SLF001 - axis._update_readback(pos) # noqa: SLF001 - if abs(pos - target) <= axis._tolerance: # noqa: SLF001 - return - self._raise_if_error() - if time.monotonic() > deadline: - raise TimeoutError(f"{axis.name}: move to {target} timed out") - time.sleep(axis._poll_interval) # noqa: SLF001 - - def _axis_for(self, axis: Axis) -> SmargonAxis: - for ax in self._all_axes(): - if ax._axis is axis: # noqa: SLF001 - return ax - raise KeyError(axis) + """Coordinated multi-axis move in one ``/targetSCS`` request.""" + self.controller.move_scs(wait=wait, **targets) def nudge_beamline(self, modify_offset: bool = False, **deltas: float) -> None: - """Nudge the sample along beamline axes (``BX``/``BY``/``BZ``). - - With ``modify_offset=True`` the O-vector is nudged (``/nudgeOBCS``). - """ - self._ready_guard(0.0) - parsed = {BeamlineAxis(k): float(v) for k, v in deltas.items()} - with self._transport_lock: - self.transport.nudge_beamline(parsed, modify_offset=modify_offset) + self.controller.nudge_beamline(modify_offset=modify_offset, **deltas) def motor_positions(self) -> dict: - """Return the raw ``q1..q6`` motor positions (read-only diagnostics).""" - return self._get_info().motor_positions + return self.controller.motor_positions() + + def stop_all(self) -> None: + self.controller.stop_all() def transport_info(self) -> dict: - """Return connection/identity info for diagnostics.""" - info = self._get_info() if self.transport.connected else None - return { - "connected": self.transport.connected, - "simulation": self._simulation, - "has_omega": self._has_omega, - "mode": info.mode.name if info else None, - "serial_number": getattr(info, "serial_number", None), - "seq": getattr(info, "seq", None), - } + return self.controller.transport_info() diff --git a/tests/tests_devices/test_smargon_device.py b/tests/tests_devices/test_smargon_device.py index 64a1eda8..8f10b5c5 100644 --- a/tests/tests_devices/test_smargon_device.py +++ b/tests/tests_devices/test_smargon_device.py @@ -1,162 +1,198 @@ -"""Tests for the SmarGon ophyd device (simulation mode, no hardware).""" +"""Tests for the per-axis SmarGon devices (controller + motor + coordinator). + +All run against the in-memory FakeTransport (no hardware/server). +""" import pytest -from ophyd import Kind -from ophyd.utils import LimitError +from ophyd.utils import LimitError, ReadOnlyError -from csaxs_bec.devices.smargon import SmarGon +from csaxs_bec.devices.smargon import SmarGon, SmargopoloController, SmargopoloMotor from csaxs_bec.devices.smargon.transport import FakeTransport, Mode from csaxs_bec.devices.smargon.transport.errors import SmargopoloCommandError, SmargopoloModeError -@pytest.fixture -def smargon(): - device = SmarGon(name="smargon_test", simulation=True, has_omega=True) - device.wait_for_connection() - yield device - device.destroy() +@pytest.fixture(autouse=True) +def _reset_controller_singletons(): + """Each test gets fresh controllers (the singleton registry is class-level).""" + SmargopoloController._controller_instances.clear() + yield + SmargopoloController._controller_instances.clear() -def test_uses_fake_transport(smargon): - assert isinstance(smargon.transport, FakeTransport) - assert smargon.transport.connected +def _motor(name, scs_axis, **cfg): + m = SmargopoloMotor(name=name, scs_axis=scs_axis, simulation=True, **cfg) + m.wait_for_connection() + return m -def test_v1_axes_are_positioners(smargon): - for ax in (smargon.shx, smargon.shy, smargon.shz, smargon.chi, smargon.phi): - assert hasattr(ax, "move") and hasattr(ax, "stop") - assert ax.kind != Kind.omitted +def _coordinator(**cfg): + c = SmarGon(name="smargon", simulation=True, **cfg) + c.wait_for_connection() + return c -def test_refuses_move_until_referenced(smargon): - assert smargon.get_mode() == "UNINITIALIZED" +# -- controller singleton --------------------------------------------------- + + +def test_axes_and_coordinator_share_one_controller(): + coord = _coordinator() + sgx = _motor("sgx", "SHX") + sgy = _motor("sgy", "SHY") + assert coord.controller is sgx.controller is sgy.controller + assert coord.controller.transport is sgx.controller.transport + + +def test_real_and_sim_are_distinct_controllers(): + sim = SmargopoloMotor(name="a", scs_axis="SHX", simulation=True) + real = SmargopoloMotor(name="b", scs_axis="SHX", simulation=False, host="h", port=1) + assert sim.controller is not real.controller + assert isinstance(sim.controller.transport, FakeTransport) + + +# -- motor as a BEC-native positioner --------------------------------------- + + +def test_motor_is_positioner_with_config_limits(): + sgx = _motor("sgx", "SHX", limits=[-2, 2]) + assert sgx.limits == (-2.0, 2.0) + assert sgx.low_limit_travel.get() == -2.0 and sgx.high_limit_travel.get() == 2.0 + assert hasattr(sgx, "move") and hasattr(sgx, "stop") + + +def test_readback_is_aliased_to_device_name(): + """dev.wm relies on the readback key equalling the device name.""" + sgx = _motor("sgx", "SHX") + assert "sgx" in sgx.read() # not 'sgx_readback' + assert "sgx_setpoint" in sgx.read() + + +def test_default_egu_from_axis(): + assert _motor("sgx", "SHX").egu == "mm" + assert _motor("sgchi", "CHI").egu == "deg" + + +def test_refuses_move_until_referenced(): + sgx = _motor("sgx", "SHX") + assert sgx.controller.get_mode() == "UNINITIALIZED" with pytest.raises(SmargopoloModeError): - smargon.shx.move(0.5) + sgx.move(0.5) -def test_reference_then_move(smargon): - smargon.reference() - assert smargon.is_ready() - status = smargon.shx.move(0.5, wait=True) - assert status.done and status.success - assert smargon.shx.position == pytest.approx(0.5) +def test_reference_then_move(): + coord = _coordinator() + sgx = _motor("sgx", "SHX", limits=[-2, 2]) + coord.reference() # shared controller -> sgx is now movable + assert sgx.controller.is_ready() + st = sgx.move(0.5, wait=True) + assert st.done and st.success + assert sgx.position == pytest.approx(0.5) -def test_set_alias_is_nonblocking(smargon): - smargon.reference() - status = smargon.chi.set(12.0) - status.wait(timeout=5) - assert status.success - assert smargon.chi.position == pytest.approx(12.0) - - -def test_omega_enabled_when_has_omega(smargon): - assert smargon.omega.kind != Kind.omitted - smargon.reference() - smargon.omega.move(45.0, wait=True) - assert smargon.omega.position == pytest.approx(45.0) - - -def test_omega_disabled_without_has_omega(): - device = SmarGon(name="no_omega", simulation=True, has_omega=False) - device.wait_for_connection() - try: - device.reference() - assert device.omega.kind == Kind.omitted - with pytest.raises(SmargopoloModeError): - device.omega.move(45.0) - finally: - device.destroy() +def test_set_is_nonblocking(): + coord = _coordinator() + sgchi = _motor("sgchi", "CHI") + coord.reference() + st = sgchi.set(12.0) + st.wait(timeout=5) + assert st.success and sgchi.position == pytest.approx(12.0) def test_soft_limits_enforced(): - device = SmarGon(name="lim", simulation=True, limits={"SHX": [-1.0, 1.0]}) - device.wait_for_connection() - try: - device.reference() - assert device.shx.limits == (-1.0, 1.0) - with pytest.raises(LimitError): - device.shx.move(2.0) - finally: - device.destroy() + coord = _coordinator() + sgx = _motor("sgx", "SHX", limits=[-1, 1]) + coord.reference() + with pytest.raises(LimitError): + sgx.move(2.0) -def test_per_axis_tolerance_applied(): - device = SmarGon(name="tol", simulation=True, tolerance={"SHX": 0.05}) - device.wait_for_connection() - try: - assert device.shx._tolerance == pytest.approx(0.05) - finally: - device.destroy() +def test_zero_zero_limits_mean_unbounded(): + coord = _coordinator() + sgo = _motor("sgo", "OMEGA") # no limits configured -> (0, 0) + coord.reference() + assert sgo.limits == (0.0, 0.0) + sgo.move(123.0, wait=True) # not rejected + assert sgo.position == pytest.approx(123.0) -def test_move_scs_coordinated(smargon): - smargon.reference() - smargon.move_scs(SHX=0.3, SHZ=19.0, wait=True) - assert smargon.shx.position == pytest.approx(0.3) - assert smargon.shz.position == pytest.approx(19.0) +# -- coordinator ------------------------------------------------------------ -def test_stop_all_does_not_raise(smargon): - smargon.reference() - smargon.shx.set(0.2) - smargon.stop_all() +def test_coordinator_reference_and_mode(): + coord = _coordinator() + assert coord.get_mode() == "UNINITIALIZED" + coord.reference() + assert coord.is_ready() and coord.get_mode() == "READY" -def test_motor_positions_diagnostic(smargon): - motors = smargon.motor_positions() - assert set(motors) == {"q1", "q2", "q3", "q4", "q5", "q6"} +def test_move_scs_coordinated(): + coord = _coordinator() + sgx = _motor("sgx", "SHX", limits=[-2, 2]) + sgz = _motor("sgz", "SHZ", limits=[10, 22]) + coord.reference() + coord.move_scs(SHX=0.3, SHZ=19.0, wait=True) + assert sgx.position == pytest.approx(0.3) + assert sgz.position == pytest.approx(19.0) -def test_transport_info(smargon): - info = smargon.transport_info() - assert info["simulation"] is True - assert info["has_omega"] is True - assert info["connected"] is True +def test_move_scs_respects_limits(): + coord = _coordinator() + _motor("sgz", "SHZ", limits=[10, 22]) + coord.reference() + with pytest.raises(LimitError): + coord.move_scs(SHZ=0.0) # below the SHZ range -def test_move_fails_when_target_rejected_up_front(smargon): - """smargopolo refusing an out-of-range target -> immediate move failure.""" - smargon.reference() - smargon.transport.fail_next["move_absolute"] = SmargopoloCommandError("target out of range") - status = smargon.shx.set(0.5) +def test_motor_positions_and_transport_info(): + coord = _coordinator() + assert set(coord.motor_positions()) == {"q1", "q2", "q3", "q4", "q5", "q6"} + info = coord.transport_info() + assert info["simulation"] is True and info["connected"] is True + + +def test_mode_signal_is_readonly(): + coord = _coordinator() + assert coord.mode.get() == int(Mode.UNINITIALIZED) + with pytest.raises(ReadOnlyError): + coord.mode.put(2) + + +def test_controller_reachable_from_axis(): + coord = _coordinator() + sgx = _motor("sgx", "SHX") + # dev..controller.reference() path + sgx.controller.reference() + assert coord.is_ready() + + +# -- fault handling --------------------------------------------------------- + + +def test_move_fails_when_target_rejected_up_front(): + coord = _coordinator() + sgx = _motor("sgx", "SHX", limits=[-2, 2]) + coord.reference() + coord.controller.transport.fail_next["move_absolute"] = SmargopoloCommandError("nope") + st = sgx.set(0.5) with pytest.raises(SmargopoloCommandError): - status.wait(timeout=5) - assert not status.success + st.wait(timeout=5) + assert not st.success def test_move_aborts_when_controller_faults_mid_move(): - """Target accepted (202) but a coupled-limit fault appears after partial motion.""" - device = SmarGon(name="faulter", simulation=True, gradual_motion=True) - device.wait_for_connection() - try: - device.reference() - status = device.shx.set(1e6) # far target: gradual motion keeps it in-flight - # Simulate smargopolo hitting a coupled hardware limit mid-move. - device.transport._mode = Mode.ERROR - with pytest.raises(SmargopoloModeError): - status.wait(timeout=5) - assert not status.success - assert device.shx.motor_is_moving.get() == 0 - finally: - device.destroy() + coord = _coordinator(gradual_motion=True) + sgx = _motor("sgx", "SHX", gradual_motion=True) # shares the gradual controller + coord.reference() + st = sgx.set(1e6) # far target: gradual motion keeps it in-flight + coord.controller.transport._mode = Mode.ERROR + with pytest.raises(SmargopoloModeError): + st.wait(timeout=5) + assert not st.success and sgx.motor_is_moving.get() == 0 def test_move_scs_aborts_on_controller_fault(): - device = SmarGon(name="faulter2", simulation=True, gradual_motion=True) - device.wait_for_connection() - try: - device.reference() - device.transport._mode = Mode.ERROR - with pytest.raises(SmargopoloModeError): - device.move_scs(SHX=1e6, wait=True) - finally: - device.destroy() - - -def test_mode_signal_is_readonly(smargon): - from ophyd.utils import ReadOnlyError - - assert smargon.mode.get() == int(Mode.UNINITIALIZED) - with pytest.raises(ReadOnlyError): - smargon.mode.put(2) + coord = _coordinator(gradual_motion=True) + _motor("sgx", "SHX", gradual_motion=True) + coord.reference() + coord.controller.transport._mode = Mode.ERROR + with pytest.raises(SmargopoloModeError): + coord.move_scs(SHX=1e6, wait=True) -- 2.54.0 From 3bb4bf60dae094ce7ad57c73e5a19076358b2baf Mon Sep 17 00:00:00 2001 From: menzel Date: Mon, 6 Jul 2026 15:42:19 +0200 Subject: [PATCH 4/6] chore(smargon): tidy sim session config (reflow, comment out sgomega) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../device_configs/smargon_sim_session.yaml | 63 ++++++++++++++----- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/csaxs_bec/device_configs/smargon_sim_session.yaml b/csaxs_bec/device_configs/smargon_sim_session.yaml index 61b98f12..d04fed72 100644 --- a/csaxs_bec/device_configs/smargon_sim_session.yaml +++ b/csaxs_bec/device_configs/smargon_sim_session.yaml @@ -16,7 +16,7 @@ smargon: description: SmarGon coordinator (SIMULATION) deviceClass: csaxs_bec.devices.smargon.smargon.SmarGon - deviceConfig: {simulation: true, gradual_motion: true} + deviceConfig: { simulation: true, gradual_motion: true } deviceTags: [goniometer, smargon, simulation] enabled: true onFailure: retry @@ -25,7 +25,14 @@ smargon: sgx: description: SmarGon SHX (sim) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: SHX, simulation: true, gradual_motion: true, limits: [-2, 2], tolerance: 0.001} + deviceConfig: + { + scs_axis: SHX, + simulation: true, + gradual_motion: true, + limits: [-2, 2], + tolerance: 0.001, + } deviceTags: [goniometer, smargon, simulation] enabled: true onFailure: retry @@ -34,7 +41,14 @@ sgx: sgy: description: SmarGon SHY (sim) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: SHY, simulation: true, gradual_motion: true, limits: [-2, 2], tolerance: 0.001} + deviceConfig: + { + scs_axis: SHY, + simulation: true, + gradual_motion: true, + limits: [-2, 2], + tolerance: 0.001, + } deviceTags: [goniometer, smargon, simulation] enabled: true onFailure: retry @@ -43,7 +57,14 @@ sgy: sgz: description: SmarGon SHZ (sim) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: SHZ, simulation: true, gradual_motion: true, limits: [10, 22], tolerance: 0.001} + deviceConfig: + { + scs_axis: SHZ, + simulation: true, + gradual_motion: true, + limits: [10, 22], + tolerance: 0.001, + } deviceTags: [goniometer, smargon, simulation] enabled: true onFailure: retry @@ -52,7 +73,14 @@ sgz: sgchi: description: SmarGon CHI (sim) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: CHI, simulation: true, gradual_motion: true, limits: [0, 65], tolerance: 0.01} + deviceConfig: + { + scs_axis: CHI, + simulation: true, + gradual_motion: true, + limits: [0, 65], + tolerance: 0.01, + } deviceTags: [goniometer, smargon, simulation] enabled: true onFailure: retry @@ -61,20 +89,27 @@ sgchi: sgphi: description: SmarGon PHI (sim) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: PHI, simulation: true, gradual_motion: true, limits: [-180, 180], tolerance: 0.01} + deviceConfig: + { + scs_axis: PHI, + simulation: true, + gradual_motion: true, + limits: [-180, 180], + tolerance: 0.01, + } deviceTags: [goniometer, smargon, simulation] enabled: true onFailure: retry readoutPriority: baseline -sgomega: - description: SmarGon OMEGA (sim) - deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: OMEGA, simulation: true, gradual_motion: true, tolerance: 0.01} - deviceTags: [goniometer, smargon, simulation] - enabled: true - onFailure: retry - readoutPriority: baseline +# sgomega: +# description: SmarGon OMEGA (sim) +# deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor +# deviceConfig: {scs_axis: OMEGA, simulation: true, gradual_motion: true, tolerance: 0.01} +# deviceTags: [goniometer, smargon, simulation] +# enabled: true +# onFailure: retry +# readoutPriority: baseline sim_mon: description: Simulated 1D monitor (readout for test scans over SmarGon axes) -- 2.54.0 From 6ef4cc61132291872df753d1e6a666a2e45a7ff4 Mon Sep 17 00:00:00 2001 From: menzel Date: Mon, 6 Jul 2026 18:58:40 +0200 Subject: [PATCH 5/6] config(smargon): point beamline config at tt-smargopolo:8080 The cSAXS beamline smargopolo server is http://tt-smargopolo:8080 (port 8080, not the MX-lab reference's :3000). Set host/port on the coordinator and all axis motors so smargon.yaml loads as-is at the beamline. Co-Authored-By: Claude Opus 4.8 (1M context) --- csaxs_bec/device_configs/smargon.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/csaxs_bec/device_configs/smargon.yaml b/csaxs_bec/device_configs/smargon.yaml index bcbe8897..bcf95daa 100644 --- a/csaxs_bec/device_configs/smargon.yaml +++ b/csaxs_bec/device_configs/smargon.yaml @@ -16,8 +16,8 @@ smargon: description: SmarGon coordinator (referencing, mode, coordinated moves) deviceClass: csaxs_bec.devices.smargon.smargon.SmarGon deviceConfig: - host: smargopolo # SET to the actual server (ref client used "mxlab1-smargopolo") - port: 3000 + host: tt-smargopolo # cSAXS beamline smargopolo server + port: 8080 # note: 8080 here (the MX-lab reference used :3000) timeout: 5.0 deviceTags: [goniometer, smargon] enabled: true @@ -33,7 +33,7 @@ smargon: sgx: description: SmarGon SHX (sample X) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: SHX, host: smargopolo, port: 3000, limits: [-2, 2], tolerance: 0.001} + deviceConfig: {scs_axis: SHX, host: tt-smargopolo, port: 8080, limits: [-2, 2], tolerance: 0.001} deviceTags: [goniometer, smargon] enabled: true onFailure: retry @@ -42,7 +42,7 @@ sgx: sgy: description: SmarGon SHY (sample Y) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: SHY, host: smargopolo, port: 3000, limits: [-2, 2], tolerance: 0.001} + deviceConfig: {scs_axis: SHY, host: tt-smargopolo, port: 8080, limits: [-2, 2], tolerance: 0.001} deviceTags: [goniometer, smargon] enabled: true onFailure: retry @@ -51,7 +51,7 @@ sgy: sgz: description: SmarGon SHZ (sample Z) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: SHZ, host: smargopolo, port: 3000, limits: [10, 22], tolerance: 0.001} + deviceConfig: {scs_axis: SHZ, host: tt-smargopolo, port: 8080, limits: [10, 22], tolerance: 0.001} deviceTags: [goniometer, smargon] enabled: true onFailure: retry @@ -60,7 +60,7 @@ sgz: sgchi: description: SmarGon CHI (goniometer tilt) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: CHI, host: smargopolo, port: 3000, limits: [0, 65], tolerance: 0.01} + deviceConfig: {scs_axis: CHI, host: tt-smargopolo, port: 8080, limits: [0, 65], tolerance: 0.01} deviceTags: [goniometer, smargon] enabled: true onFailure: retry @@ -69,7 +69,7 @@ sgchi: sgphi: description: SmarGon PHI (goniometer rotation) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: PHI, host: smargopolo, port: 3000, limits: [-180, 180], tolerance: 0.01} + deviceConfig: {scs_axis: PHI, host: tt-smargopolo, port: 8080, limits: [-180, 180], tolerance: 0.01} deviceTags: [goniometer, smargon] enabled: true onFailure: retry @@ -79,7 +79,7 @@ sgphi: # sgomega: # description: SmarGon OMEGA (rotation drive) # deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor -# deviceConfig: {scs_axis: OMEGA, host: smargopolo, port: 3000, tolerance: 0.01} +# deviceConfig: {scs_axis: OMEGA, host: tt-smargopolo, port: 8080, tolerance: 0.01} # deviceTags: [goniometer, smargon] # enabled: true # onFailure: retry -- 2.54.0 From 58f028ec9a23534bf74a00aced69e3df8f53ff44 Mon Sep 17 00:00:00 2001 From: menzel Date: Tue, 7 Jul 2026 11:53:57 +0200 Subject: [PATCH 6/6] config(smargon): REST API port is 3000, not 8080 (:8080 is the web GUI) Confirmed with the smargopolo maintainer: the JSON REST API is on :3000; the :8080 server is the web GUI (returns HTML, all API paths 404). Point host/port back to tt-smargopolo:3000 on the coordinator and all axis motors. Co-Authored-By: Claude Opus 4.8 (1M context) --- csaxs_bec/device_configs/smargon.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/csaxs_bec/device_configs/smargon.yaml b/csaxs_bec/device_configs/smargon.yaml index bcf95daa..fe004d1e 100644 --- a/csaxs_bec/device_configs/smargon.yaml +++ b/csaxs_bec/device_configs/smargon.yaml @@ -16,8 +16,8 @@ smargon: description: SmarGon coordinator (referencing, mode, coordinated moves) deviceClass: csaxs_bec.devices.smargon.smargon.SmarGon deviceConfig: - host: tt-smargopolo # cSAXS beamline smargopolo server - port: 8080 # note: 8080 here (the MX-lab reference used :3000) + 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 @@ -33,7 +33,7 @@ smargon: sgx: description: SmarGon SHX (sample X) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: SHX, host: tt-smargopolo, port: 8080, limits: [-2, 2], tolerance: 0.001} + deviceConfig: {scs_axis: SHX, host: tt-smargopolo, port: 3000, limits: [-2, 2], tolerance: 0.001} deviceTags: [goniometer, smargon] enabled: true onFailure: retry @@ -42,7 +42,7 @@ sgx: sgy: description: SmarGon SHY (sample Y) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: SHY, host: tt-smargopolo, port: 8080, limits: [-2, 2], tolerance: 0.001} + deviceConfig: {scs_axis: SHY, host: tt-smargopolo, port: 3000, limits: [-2, 2], tolerance: 0.001} deviceTags: [goniometer, smargon] enabled: true onFailure: retry @@ -51,7 +51,7 @@ sgy: sgz: description: SmarGon SHZ (sample Z) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: SHZ, host: tt-smargopolo, port: 8080, limits: [10, 22], tolerance: 0.001} + deviceConfig: {scs_axis: SHZ, host: tt-smargopolo, port: 3000, limits: [10, 22], tolerance: 0.001} deviceTags: [goniometer, smargon] enabled: true onFailure: retry @@ -60,7 +60,7 @@ sgz: sgchi: description: SmarGon CHI (goniometer tilt) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: CHI, host: tt-smargopolo, port: 8080, limits: [0, 65], tolerance: 0.01} + deviceConfig: {scs_axis: CHI, host: tt-smargopolo, port: 3000, limits: [0, 65], tolerance: 0.01} deviceTags: [goniometer, smargon] enabled: true onFailure: retry @@ -69,7 +69,7 @@ sgchi: sgphi: description: SmarGon PHI (goniometer rotation) deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor - deviceConfig: {scs_axis: PHI, host: tt-smargopolo, port: 8080, limits: [-180, 180], tolerance: 0.01} + deviceConfig: {scs_axis: PHI, host: tt-smargopolo, port: 3000, limits: [-180, 180], tolerance: 0.01} deviceTags: [goniometer, smargon] enabled: true onFailure: retry @@ -79,7 +79,7 @@ sgphi: # sgomega: # description: SmarGon OMEGA (rotation drive) # deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor -# deviceConfig: {scs_axis: OMEGA, host: tt-smargopolo, port: 8080, tolerance: 0.01} +# deviceConfig: {scs_axis: OMEGA, host: tt-smargopolo, port: 3000, tolerance: 0.01} # deviceTags: [goniometer, smargon] # enabled: true # onFailure: retry -- 2.54.0