fix(omny): simulate oshield's digital open-switch from its live position
CI for csaxs_bec / test (push) Successful in 1m57s

_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
This commit is contained in:
x01dc
2026-09-03 20:19:12 +02:00
co-authored by Claude Sonnet 5
parent 53f3686d22
commit fbc76b4fb1
3 changed files with 160 additions and 1 deletions
@@ -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
+37
View File
@@ -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])
@@ -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()