From fbc76b4fb16ec05638890189bc3685adcba87135 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 3 Sep 2026 20:19:12 +0200 Subject: [PATCH] fix(omny): simulate oshield's digital open-switch from its live position _oshield_is_ST_open() reads a digital switch (@IN[8014]) on otransx's Galil controller to confirm the sample-stage thermal shield reached its physical "open" limit -- but oshield is a Smaract stage, simulated by a completely separate simulator (sim_smaract.py), so that switch had no connection to oshield's actual simulated position and always read a stale default. sim_galil.py's @IN[8014] handler now computes the switch value live from oshield's real position via SimStateRegistry (cross-simulator lookup), instead of a disconnected default -- 1.0 within tolerance of the "open" target (-11.88, _oshield_ST_open()'s own umv() target), 0.0 otherwise. Also fixes oshield's simulated starting position: it started at 0 (neither the "open" -11.88 nor "closed" 15.5), so _otransfer_stage_to_ST()'s oshield_current < 15 closed-check always failed fresh. Now starts at 15.5, matching the real idle/closed resting state. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj --- .../simulated_omny/simulated_omny.yaml | 6 +- csaxs_bec/devices/sim/sim_galil.py | 37 ++++++ .../test_sim_galil_oshield_switch.py | 118 ++++++++++++++++++ 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 tests/tests_devices/test_sim_galil_oshield_switch.py diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml index a8c82cc6..b49e5d5b 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml @@ -709,7 +709,11 @@ oshield: - 15.8 port: 3334 sign: 1 - sim_initial_position: 0 + # Real hardware idles with the sample-stage shield closed; 15.5 is + # _oshield_ST_close()'s own umv() target (omny_sample_transfer_mixin.py). + # Starting at 0 (neither open -11.88 nor closed 15.5) always failed + # _otransfer_stage_to_ST()'s "oshield_current < 15" closed check. + sim_initial_position: 15.5 enabled: true onFailure: buffer readOnly: false diff --git a/csaxs_bec/devices/sim/sim_galil.py b/csaxs_bec/devices/sim/sim_galil.py index d6a21fc3..c61e8b86 100644 --- a/csaxs_bec/devices/sim/sim_galil.py +++ b/csaxs_bec/devices/sim/sim_galil.py @@ -46,6 +46,37 @@ _OMNY_ROTATION_AXIS = 2 # 150 degC on every axis without an explicit sim_analog_inputs override. _DEFAULT_ANALOG_INPUT_V = -8.0 +# oshield (the sample-stage thermal shield) is a Smaract stage, not a Galil axis -- +# but its "fully open" limit switch is wired into *this* Galil controller's digital +# input @IN[8014] on real hardware, read via otransx's socket since they share the +# same controller (see _oshield_is_ST_open() in omny_sample_transfer_mixin.py: "the +# switch for detecting status can be reached via galil controller 1, so we use +# otransx for access"). oshield's own simulated position lives entirely in +# sim_smaract.py's SimSmaractState (a different simulator, keyed by a different +# host/port), so without this, @IN[8014] would read a static default forever, +# disagreeing with wherever oshield actually simulated-moved to. Computed dynamically +# below instead of seeded once, since oshield's position changes during a session. +_OSHIELD_OPEN_SWITCH_CHANNEL = 8014 +_OSHIELD_GALIL_HOST = "mpc3217.psi.ch" +_OSHIELD_GALIL_PORT = 8081 # otransx/otransy's controller +_OSHIELD_SMARACT_HOST = "mpc3217.psi.ch" +_OSHIELD_SMARACT_PORT = 3334 +_OSHIELD_SMARACT_AXIS = 2 # axis_Id "C" +_OSHIELD_OPEN_POSITION_MM = -11.88 # _oshield_ST_open()'s own umv() target +_OSHIELD_OPEN_TOLERANCE_MM = 0.1 + + +def _oshield_open_switch_value() -> float: + # Imported lazily to avoid a module-level import cycle risk between the two + # simulator modules; sim_smaract.py doesn't import sim_galil.py today, but + # keeping this import local avoids ever having to care if that changes. + from csaxs_bec.devices.sim.sim_smaract import SimSmaractState + + state = SimStateRegistry.get(SimSmaractState, _OSHIELD_SMARACT_HOST, _OSHIELD_SMARACT_PORT) + position_mm = state.axis(_OSHIELD_SMARACT_AXIS).position() / 1.0e6 # nm -> mm + is_open = abs(position_mm - _OSHIELD_OPEN_POSITION_MM) < _OSHIELD_OPEN_TOLERANCE_MM + return 1.0 if is_open else 0.0 + class _SimGalilAxis: """State of a single simulated Galil axis (positions in microsteps).""" @@ -494,6 +525,12 @@ class SimGalilSocket(SimSocketBase): return "0.0000" if arg.startswith("@IN["): channel = int(arg[4:].split("]")[0]) + if ( + channel == _OSHIELD_OPEN_SWITCH_CHANNEL + and state.host == _OSHIELD_GALIL_HOST + and state.port == _OSHIELD_GALIL_PORT + ): + return f"{_oshield_open_switch_value():.4f}" return f"{state.digital_inputs.get(channel, 0.0):.4f}" if arg.startswith("@OUT["): channel = int(arg[5:].split("]")[0]) diff --git a/tests/tests_devices/test_sim_galil_oshield_switch.py b/tests/tests_devices/test_sim_galil_oshield_switch.py new file mode 100644 index 00000000..f33d1f07 --- /dev/null +++ b/tests/tests_devices/test_sim_galil_oshield_switch.py @@ -0,0 +1,118 @@ +import pytest +from bec_server.device_server.tests.utils import DMMock + +from csaxs_bec.devices.omny.galil.ogalil_ophyd import OMNYGalilController +from csaxs_bec.devices.sim.sim_galil import ( + _OSHIELD_GALIL_HOST, + _OSHIELD_GALIL_PORT, + _OSHIELD_OPEN_POSITION_MM, + _OSHIELD_SMARACT_AXIS, + _OSHIELD_SMARACT_PORT, +) +from csaxs_bec.devices.sim.sim_omny import SimOMNYGalilMotor +from csaxs_bec.devices.sim.sim_smaract import SimSmaractMotor, SimSmaractState +from csaxs_bec.devices.sim.sim_socket import SimStateRegistry + +# otransx's own axis letter doesn't matter here -- only its host/port (shared with +# oshield's "open" limit switch on real hardware, see sim_galil.py's +# _OSHIELD_GALIL_HOST/_OSHIELD_GALIL_PORT). +_OTRANSX_AXIS_ID = "D" + + +@pytest.fixture(autouse=True) +def _reset_sim_state(): + OMNYGalilController._reset_controller() + SimStateRegistry.reset() + yield + SimStateRegistry.reset() + OMNYGalilController._reset_controller() + + +def _make_devices(oshield_initial_position): + otransx = SimOMNYGalilMotor( + _OTRANSX_AXIS_ID, + name="otransx", + host=_OSHIELD_GALIL_HOST, + port=_OSHIELD_GALIL_PORT, + sign=1, + device_manager=DMMock(), + sim_initial_position=0, + sim_velocity=10.0, + ) + otransx.wait_for_connection() + oshield = SimSmaractMotor( + "C", + name="oshield", + host=_OSHIELD_GALIL_HOST, + port=_OSHIELD_SMARACT_PORT, + sign=1, + device_manager=DMMock(), + sim_initial_position=oshield_initial_position, + sim_velocity=10.0, + ) + oshield.wait_for_connection() + return otransx, oshield + + +def _switch_value(otransx) -> float: + return float(otransx.controller.socket_put_and_receive("MG @IN[8014]")) + + +def test_switch_reads_closed_when_oshield_is_closed(): + """oshield closed (15.5, _oshield_ST_close()'s own target) -> switch not + triggered, matching _oshield_is_ST_open()'s real-hardware semantics.""" + otransx, oshield = _make_devices(15.5) + try: + assert _switch_value(otransx) == 0.0 + finally: + otransx.destroy() + oshield.destroy() + + +def test_switch_reads_open_when_oshield_is_at_open_position(): + otransx, oshield = _make_devices(_OSHIELD_OPEN_POSITION_MM) + try: + assert _switch_value(otransx) == 1.0 + finally: + otransx.destroy() + oshield.destroy() + + +def test_switch_tracks_oshield_position_live_not_just_at_startup(): + """The switch must reflect oshield's *current* simulated position, not + just whatever it started at -- this is the actual bug being fixed: + the digital switch and oshield's position are simulated by two + different simulator classes (Galil vs Smaract) that don't otherwise + talk to each other.""" + otransx, oshield = _make_devices(15.5) + try: + assert _switch_value(otransx) == 0.0 + state = SimStateRegistry.get(SimSmaractState, _OSHIELD_GALIL_HOST, _OSHIELD_SMARACT_PORT) + state.axis(_OSHIELD_SMARACT_AXIS).pos_nm = _OSHIELD_OPEN_POSITION_MM * 1.0e6 + assert _switch_value(otransx) == 1.0 + finally: + otransx.destroy() + oshield.destroy() + + +def test_switch_reads_closed_at_an_intermediate_position(): + otransx, oshield = _make_devices(0.0) + try: + assert _switch_value(otransx) == 0.0 + finally: + otransx.destroy() + oshield.destroy() + + +def test_other_channels_unaffected_by_the_oshield_special_case(): + """A different digital-input channel on the same controller must still + use the plain seeded/default value, not get swept up in the oshield + special-case.""" + otransx, oshield = _make_devices(15.5) + try: + # channel 14 (light curtain) has its own module-level default (1.0), + # independent of oshield's position + assert float(otransx.controller.socket_put_and_receive("MG @IN[14]")) == 1.0 + finally: + otransx.destroy() + oshield.destroy()