Files
csaxs_bec/tests/tests_devices/test_smargon_device.py
T
menzelandClaude Opus 4.8 e71f2df284 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.<axis>.controller.<op>() (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) <noreply@anthropic.com>
2026-07-06 14:19:24 +02:00

199 lines
6.2 KiB
Python

"""Tests for the per-axis SmarGon devices (controller + motor + coordinator).
All run against the in-memory FakeTransport (no hardware/server).
"""
import pytest
from ophyd.utils import LimitError, ReadOnlyError
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(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 _motor(name, scs_axis, **cfg):
m = SmargopoloMotor(name=name, scs_axis=scs_axis, simulation=True, **cfg)
m.wait_for_connection()
return m
def _coordinator(**cfg):
c = SmarGon(name="smargon", simulation=True, **cfg)
c.wait_for_connection()
return c
# -- 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):
sgx.move(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_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():
coord = _coordinator()
sgx = _motor("sgx", "SHX", limits=[-1, 1])
coord.reference()
with pytest.raises(LimitError):
sgx.move(2.0)
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)
# -- coordinator ------------------------------------------------------------
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_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_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_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.<axis>.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):
st.wait(timeout=5)
assert not st.success
def test_move_aborts_when_controller_faults_mid_move():
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():
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)