feat(sim): add simulated flOMNI endstation (galil, smaract, rt, cameras)
Protocol-level simulation of the flOMNI hardware injected via socket_cls: real device/controller classes run unchanged against state machines implementing fgalil.dmc, galil_micos_upr.dmc, the Smaract MCS protocol and the Orchestra CommunicationServer. Cameras reuse the real device classes with a synthetic frame source. Includes simulated_flomni device config (boots referenced at 'in' positions) and an offline harness covering moves, referencing, rt feedback/tracker, flyer scan readout and the gripper transfer routine.
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
############################################################
|
||||
# Simulated flOMNI endstation
|
||||
#
|
||||
# Mirrors ptycho_flomni.yaml with the hardware replaced by protocol-level
|
||||
# simulations (csaxs_bec.devices.sim.*). The real ophyd device and controller
|
||||
# classes are used; only the socket transport / frame source is simulated.
|
||||
#
|
||||
# sim_* keys in deviceConfig:
|
||||
# sim_initial_position boot position in user units (axes start referenced)
|
||||
# sim_velocity axis speed in user units per second (from fgalil.dmc)
|
||||
# sim_stppermm microsteps per mm (from fgalil.dmc #INIT tables)
|
||||
# sim_referenced boot referenced state (default true)
|
||||
# sim_point_dwell_s RT scan time per position
|
||||
#
|
||||
# Not included in iteration 1: flomni_samples (EPICS), flomni_temphum (EPICS),
|
||||
# omny_panda, omny_xray_gui.
|
||||
############################################################
|
||||
|
||||
############################################################
|
||||
#################### flOMNI Galil motors ###################
|
||||
############################################################
|
||||
|
||||
feyex:
|
||||
description: Xray eye X (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFlomniGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: D
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- -30
|
||||
- -1
|
||||
port: 8082
|
||||
sign: 1
|
||||
sim_stppermm: 25600
|
||||
sim_velocity: 5
|
||||
sim_initial_position: -1
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: -16.453
|
||||
out: -1
|
||||
fttrx_in: 2.3
|
||||
fttrx_out: -24
|
||||
|
||||
feyey:
|
||||
description: Xray eye Y (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFlomniGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: E
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- -1
|
||||
- -10
|
||||
port: 8082
|
||||
sign: 1
|
||||
sim_stppermm: 25600
|
||||
sim_velocity: 5
|
||||
sim_initial_position: -1
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: -10.09
|
||||
|
||||
fheater:
|
||||
description: Heater Y (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFlomniGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: C
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- -15
|
||||
- 0
|
||||
port: 8082
|
||||
sign: -1
|
||||
sim_stppermm: 10240
|
||||
sim_velocity: 0.1
|
||||
sim_initial_position: 0
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
|
||||
foptx:
|
||||
description: Optics X (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFlomniGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: B
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- -17
|
||||
- -12
|
||||
port: 8082
|
||||
sign: 1
|
||||
sim_stppermm: 6400
|
||||
sim_velocity: 1
|
||||
sim_initial_position: -13.831
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: true
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
#170 micron, 60 nm
|
||||
in: -13.831
|
||||
out: -13.831
|
||||
|
||||
fopty:
|
||||
description: Optics Y (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFlomniGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: F
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- 0
|
||||
- 4
|
||||
port: 8082
|
||||
sign: 1
|
||||
sim_stppermm: 128000
|
||||
sim_velocity: 0.05
|
||||
sim_initial_position: 0.42
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: true
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
#170 micron, 60 nm
|
||||
in: 0.42
|
||||
out: 0.57
|
||||
|
||||
foptz:
|
||||
description: Optics Z (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFlomniGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: A
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- 0
|
||||
- 27
|
||||
port: 8082
|
||||
sign: 1
|
||||
sim_stppermm: 6400
|
||||
sim_velocity: 1
|
||||
sim_initial_position: 23
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: 23
|
||||
|
||||
fsamroy:
|
||||
description: Sample rotation (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFuprGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: A
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- -5
|
||||
- 365
|
||||
port: 8084
|
||||
sign: -1
|
||||
sim_velocity: 25
|
||||
sim_initial_position: 0
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
|
||||
fsamx:
|
||||
description: Sample coarse X (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFlomniGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: E
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- -162
|
||||
- 0
|
||||
port: 8081
|
||||
sign: 1
|
||||
sim_stppermm: 2560
|
||||
sim_velocity: 50
|
||||
sim_initial_position: -1.14
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: true
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: -1.14
|
||||
|
||||
fsamy:
|
||||
description: Sample coarse Y (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFlomniGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: F
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- 2
|
||||
- 3.8
|
||||
port: 8081
|
||||
sign: 1
|
||||
sim_stppermm: 1280000
|
||||
sim_velocity: 0.1
|
||||
sim_initial_position: 3
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: true
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: 3
|
||||
|
||||
ftracky:
|
||||
description: Laser Tracker coarse Y (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFlomniGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: H
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- 2.2
|
||||
- 2.8
|
||||
port: 8082
|
||||
sign: 1
|
||||
sim_stppermm: 115200
|
||||
sim_velocity: 0.02
|
||||
sim_initial_position: 2.5
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
|
||||
ftrackz:
|
||||
description: Laser Tracker coarse Z (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFlomniGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: G
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- 4.5
|
||||
- 5.5
|
||||
port: 8082
|
||||
sign: 1
|
||||
sim_stppermm: 115200
|
||||
sim_velocity: 0.02
|
||||
sim_initial_position: 5
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
|
||||
ftransx:
|
||||
description: Sample transer X (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFlomniGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: C
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- 0
|
||||
- 50
|
||||
port: 8081
|
||||
sign: 1
|
||||
sim_stppermm: 25600
|
||||
sim_velocity: 4
|
||||
sim_initial_position: 0
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
|
||||
ftransy:
|
||||
description: Sample transer Y (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFlomniGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: A
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- -100
|
||||
- 0
|
||||
port: 8081
|
||||
sign: 1
|
||||
sim_stppermm: 25600
|
||||
sim_velocity: 5
|
||||
sim_initial_position: 0
|
||||
sim_analog_inputs:
|
||||
1: -1.6
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
sensor_voltage: -1.6
|
||||
|
||||
ftransz:
|
||||
description: Sample transer Z (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFlomniGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: B
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- 0
|
||||
- 145
|
||||
port: 8081
|
||||
sign: 1
|
||||
sim_stppermm: 25600
|
||||
sim_velocity: 8
|
||||
sim_initial_position: 0
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
|
||||
ftray:
|
||||
description: Sample transfer tray (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_galil.SimFlomniGalilMotor
|
||||
deviceConfig:
|
||||
axis_Id: D
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- -200
|
||||
- 0
|
||||
port: 8081
|
||||
sign: -1
|
||||
sim_stppermm: 25600
|
||||
sim_velocity: 8
|
||||
sim_initial_position: 0
|
||||
# tray boots at the 'IN' position (low limit switch active, see check_tray_in)
|
||||
sim_limit_low_active: true
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
|
||||
############################################################
|
||||
#################### flOMNI Smaract motors #################
|
||||
############################################################
|
||||
|
||||
fosax:
|
||||
description: OSA X (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor
|
||||
deviceConfig:
|
||||
axis_Id: A
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- 10.2
|
||||
- 10.6
|
||||
port: 3334
|
||||
sign: -1
|
||||
sim_velocity: 1
|
||||
sim_initial_position: 10.4
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
#170 micron, 60 nm, 7.9 kev
|
||||
in: 8.727079
|
||||
out: 5.1
|
||||
|
||||
fosay:
|
||||
description: OSA Y (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor
|
||||
deviceConfig:
|
||||
axis_Id: B
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- -3.1
|
||||
- -2.9
|
||||
port: 3334
|
||||
sign: -1
|
||||
sim_velocity: 1
|
||||
sim_initial_position: -3
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
#170 micron, 60 nm, 7.9 kev
|
||||
in: -0.04603
|
||||
|
||||
fosaz:
|
||||
description: OSA Z (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor
|
||||
deviceConfig:
|
||||
axis_Id: C
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- -6
|
||||
- -4
|
||||
port: 3334
|
||||
sign: 1
|
||||
sim_velocity: 1
|
||||
sim_initial_position: -5
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
#170 micron, 60 nm, 7.9 kev, foptz 16.9, probe size 7.5 mu
|
||||
in: 13.1
|
||||
out: 6
|
||||
|
||||
############################################################
|
||||
#################### flOMNI RT motors ######################
|
||||
############################################################
|
||||
|
||||
rtx:
|
||||
description: flomni rt (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_rt_flomni.SimRtFlomniMotor
|
||||
deviceConfig:
|
||||
limits:
|
||||
- -200
|
||||
- 200
|
||||
axis_Id: A
|
||||
host: mpc2844.psi.ch
|
||||
port: 2222
|
||||
sign: 1
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: on_request
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
low_signal: 8500
|
||||
min_signal: 8000
|
||||
rt_pid_voltage: -0.06219
|
||||
|
||||
rty:
|
||||
description: flomni rt (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_rt_flomni.SimRtFlomniMotor
|
||||
deviceConfig:
|
||||
limits:
|
||||
- -100
|
||||
- 100
|
||||
axis_Id: B
|
||||
host: mpc2844.psi.ch
|
||||
port: 2222
|
||||
sign: 1
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: on_request
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
tomo_additional_offsety: 0
|
||||
|
||||
rtz:
|
||||
description: flomni rt (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_rt_flomni.SimRtFlomniMotor
|
||||
deviceConfig:
|
||||
limits:
|
||||
- -100
|
||||
- 100
|
||||
axis_Id: C
|
||||
host: mpc2844.psi.ch
|
||||
port: 2222
|
||||
sign: 1
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: on_request
|
||||
connectionTimeout: 20
|
||||
|
||||
rt_positions:
|
||||
deviceClass: csaxs_bec.devices.sim.sim_rt_flomni.SimRtFlomniFlyer
|
||||
deviceConfig:
|
||||
host: mpc2844.psi.ch
|
||||
port: 2222
|
||||
# time per scan position; determines simulated scan duration
|
||||
sim_point_dwell_s: 0.02
|
||||
readoutPriority: async
|
||||
connectionTimeout: 20
|
||||
enabled: true
|
||||
readOnly: False
|
||||
|
||||
############################################################
|
||||
####################### Cameras ############################
|
||||
############################################################
|
||||
|
||||
cam_flomni_gripper:
|
||||
description: Camera sample changer (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_cameras.SimWebcamViewer
|
||||
deviceConfig:
|
||||
url: http://flomnicamserver:5000/video_high
|
||||
num_rotation_90: 3
|
||||
transpose: false
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: on_request
|
||||
|
||||
cam_flomni_overview:
|
||||
description: Camera flomni overview (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_cameras.SimWebcamViewer
|
||||
deviceConfig:
|
||||
url: http://flomnicamserver:5001/video_high
|
||||
num_rotation_90: 3
|
||||
transpose: false
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: on_request
|
||||
|
||||
cam_xeye:
|
||||
description: Camera flOMNI Xray eye ID1 (simulated)
|
||||
deviceClass: csaxs_bec.devices.sim.sim_cameras.SimIDSCamera
|
||||
deviceConfig:
|
||||
camera_id: 11
|
||||
bits_per_pixel: 24
|
||||
num_rotation_90: 0
|
||||
transpose: true
|
||||
force_monochrome: true
|
||||
m_n_colormode: 1
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: async
|
||||
userParameter:
|
||||
pixel_calibration: 0.00044247787610619477 # mm/pixel (= 0.05 / 113)
|
||||
|
||||
############################################################
|
||||
#################### Computed signal #######################
|
||||
############################################################
|
||||
|
||||
calculated_signal:
|
||||
description: Calculated signal from alignment for fit
|
||||
deviceClass: ophyd_devices.ComputedSignal
|
||||
deviceConfig:
|
||||
compute_method: "def just_rand():\n return 42"
|
||||
enabled: true
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
|
||||
############################################################
|
||||
######### Simulated beamline devices (shutter, DDG) ########
|
||||
############################################################
|
||||
|
||||
ddg1:
|
||||
description: Simulated main delay generator for triggering
|
||||
deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.SimulatedDDG1
|
||||
enabled: true
|
||||
deviceConfig:
|
||||
prefix: 'X12SA-CPCL-DDG1:'
|
||||
onFailure: raise
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
softwareTrigger: true
|
||||
|
||||
fsh:
|
||||
description: Simulated fast shutter manual control and readback
|
||||
deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.cSAXSSimulatedFastShutter
|
||||
deviceConfig:
|
||||
prefix: 'X12SA-ES1-TTL:'
|
||||
onFailure: raise
|
||||
enabled: true
|
||||
readoutPriority: monitored
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Simulated flOMNI cameras.
|
||||
|
||||
`SimWebcamViewer` and `SimIDSCamera` reuse the real device classes and replace only the
|
||||
frame source: the webcam viewer's MJPEG HTTP stream and the IDS camera's pyueye backend.
|
||||
All signals (`preview` / `image`, `roi_signal`, `live_mode_enabled`), the USER_ACCESS
|
||||
methods (`start_live_mode`, `set_rect_roi`, `get_last_image`, ...) and the ROI/trigger
|
||||
logic therefore run through the unmodified code paths, so GUIs and the X-ray eye
|
||||
alignment can be exercised against the simulation.
|
||||
|
||||
The synthetic frame is a static test pattern: dark background, centered gaussian blob
|
||||
and a crosshair, which gives the previews something recognizable and provides contrast
|
||||
for image analysis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from csaxs_bec.devices.ids_cameras.ids_camera import IDSCamera
|
||||
from csaxs_bec.devices.omny.webcam_viewer import WebcamViewer
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def make_test_pattern(height: int, width: int, rgb: bool) -> np.ndarray:
|
||||
"""Create a static synthetic camera frame (uint8), mono or RGB."""
|
||||
yy, xx = np.mgrid[0:height, 0:width]
|
||||
cy, cx = height / 2.0, width / 2.0
|
||||
sigma = min(height, width) / 12.0
|
||||
blob = 180.0 * np.exp(-(((yy - cy) ** 2) + ((xx - cx) ** 2)) / (2.0 * sigma**2))
|
||||
frame = 20.0 + blob
|
||||
frame[int(cy) - 1 : int(cy) + 2, :] = 230.0 # horizontal crosshair line
|
||||
frame[:, int(cx) - 1 : int(cx) + 2] = 230.0 # vertical crosshair line
|
||||
frame = frame.clip(0, 255).astype(np.uint8)
|
||||
if rgb:
|
||||
frame = np.repeat(frame[:, :, np.newaxis], 3, axis=2)
|
||||
return frame
|
||||
|
||||
|
||||
class SimWebcamViewer(WebcamViewer):
|
||||
"""WebcamViewer serving a static synthetic frame instead of an MJPEG stream."""
|
||||
|
||||
def __init__(self, url: str, name: str, sim_shape=(480, 640), **kwargs) -> None:
|
||||
self._sim_frame = make_test_pattern(int(sim_shape[0]), int(sim_shape[1]), rgb=True)
|
||||
super().__init__(url=url, name=name, **kwargs)
|
||||
|
||||
def _update_loop(self) -> None:
|
||||
while not self._shutdown_event.is_set():
|
||||
self.preview.put(self._sim_frame)
|
||||
self._shutdown_event.wait(0.2) # 5 Hz
|
||||
|
||||
|
||||
class _SimIDSSensor:
|
||||
"""Mimics the width/height ctypes attributes of the pyueye camera object."""
|
||||
|
||||
class _Value:
|
||||
def __init__(self, value: int):
|
||||
self.value = value
|
||||
|
||||
def __init__(self, width: int, height: int):
|
||||
self.width = self._Value(width)
|
||||
self.height = self._Value(height)
|
||||
|
||||
|
||||
class _SimIDSBackend:
|
||||
"""Drop-in replacement for `base_integration.camera.Camera` serving synthetic frames."""
|
||||
|
||||
def __init__(self, width: int, height: int, rgb: bool):
|
||||
self.cam = _SimIDSSensor(width, height)
|
||||
self.force_monochrome = False
|
||||
self._connected = False
|
||||
self._rgb = rgb
|
||||
self._frame = make_test_pattern(height, width, rgb=rgb)
|
||||
|
||||
def on_connect(self):
|
||||
self._connected = True
|
||||
|
||||
def on_disconnect(self):
|
||||
self._connected = False
|
||||
|
||||
def get_image_data(self) -> np.ndarray:
|
||||
frame = self._frame
|
||||
if self.force_monochrome and frame.ndim == 3:
|
||||
frame = frame[:, :, 0]
|
||||
return frame
|
||||
|
||||
def set_camera_rate_limiting(self, enabled: bool):
|
||||
pass
|
||||
|
||||
|
||||
class SimIDSCamera(IDSCamera):
|
||||
"""IDSCamera with the pyueye backend replaced by a synthetic frame source."""
|
||||
|
||||
def __init__(self, *, name: str, camera_id: int = 0, sim_shape=(1024, 1280), **kwargs):
|
||||
super().__init__(name=name, camera_id=camera_id, **kwargs)
|
||||
rgb = not kwargs.get("force_monochrome", False)
|
||||
self.cam = _SimIDSBackend(int(sim_shape[1]), int(sim_shape[0]), rgb=rgb)
|
||||
self.cam.force_monochrome = self._force_monochrome
|
||||
@@ -0,0 +1,513 @@
|
||||
"""
|
||||
Simulated Galil DMC controllers for flOMNI.
|
||||
|
||||
Implements the command subset of `fgalil.dmc` (sample/optics/eye/tracker controllers on
|
||||
ports 8081/8082) and `galil_micos_upr.dmc` (FUPR rotation stage on port 8084) that is used
|
||||
by the BEC device classes (`FlomniGalilMotor`, `FuprGalilMotor`) and by the flomni
|
||||
IPython-client scripts. The real controller classes talk to this simulation through
|
||||
`SimGalilSocket`, injected via `socket_cls`.
|
||||
|
||||
Simulated behavior ("working hardware", no error injection):
|
||||
- constant-velocity motion with linear position interpolation
|
||||
- thread 0 active while a `#NEWPAR`-initiated move is running
|
||||
- `#FES` / `#FRM` / `#REFAX` reference an axis (position -> 0, limit switch behavior)
|
||||
- gripper sample transfer routines `#GRGET` / `#GRPUT` on thread 3, including the
|
||||
`confirm` handshake (`confirm=-1` -> host sets `confirm=1`; `atconfir=1` autoconfirms)
|
||||
- `#MNTMODE` / `#POSMODE`, gripper open/close on thread 4, `#Tracker` on thread 5
|
||||
- `#STOP` aborts motion and clears `mntprgs` / `mntmod`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from csaxs_bec.devices.omny.galil.fgalil_ophyd import FlomniGalilMotor
|
||||
from csaxs_bec.devices.omny.galil.fupr_ophyd import FuprGalilMotor
|
||||
from csaxs_bec.devices.sim.sim_socket import SimSocketBase, SimStateRegistry
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_ASSIGNMENT_RE = re.compile(r"^([A-Za-z]+)(?:\[(\d+)\])?\s*=\s*(.+)$")
|
||||
_STPPERMM_EXPR_RE = re.compile(r"^([0-9.]+)\s*\*\s*stppermm\[(\d+)\]$")
|
||||
|
||||
|
||||
class _SimGalilAxis:
|
||||
"""State of a single simulated Galil axis (positions in microsteps)."""
|
||||
|
||||
def __init__(self, index: int):
|
||||
self.index = index
|
||||
self.connected = False
|
||||
self.referenced = True
|
||||
self.motor_on = True
|
||||
self.auto_motor_off = True # fgalil #MOTOFFA switches motors off when idle
|
||||
self.stppermm = 25600.0
|
||||
self.speed_steps = 5.0 * 25600.0
|
||||
self.pos_steps = 0.0
|
||||
self._move_start_steps = 0.0
|
||||
self._move_target_steps = 0.0
|
||||
self._move_t0 = 0.0
|
||||
self._move_t1 = 0.0
|
||||
self.limit_low_active = False
|
||||
self.limit_high_active = False
|
||||
|
||||
def position(self) -> float:
|
||||
now = time.time()
|
||||
if now >= self._move_t1:
|
||||
return self.pos_steps
|
||||
frac = (now - self._move_t0) / (self._move_t1 - self._move_t0)
|
||||
return self._move_start_steps + frac * (self._move_target_steps - self._move_start_steps)
|
||||
|
||||
def is_moving(self) -> bool:
|
||||
return time.time() < self._move_t1
|
||||
|
||||
def motor_is_on(self) -> bool:
|
||||
if self.auto_motor_off:
|
||||
return self.is_moving()
|
||||
return self.motor_on
|
||||
|
||||
def start_move(self, target_steps: float):
|
||||
speed = max(self.speed_steps, 1.0)
|
||||
current = self.position()
|
||||
duration = abs(target_steps - current) / speed
|
||||
self._move_start_steps = current
|
||||
self._move_target_steps = target_steps
|
||||
self._move_t0 = time.time()
|
||||
self._move_t1 = self._move_t0 + duration
|
||||
self.pos_steps = target_steps
|
||||
self.limit_low_active = False
|
||||
self.limit_high_active = False
|
||||
|
||||
def stop_move(self):
|
||||
self.pos_steps = self.position()
|
||||
self._move_t1 = 0.0
|
||||
|
||||
def wait_move(self, abort_event: threading.Event | None = None):
|
||||
while self.is_moving():
|
||||
if abort_event is not None and abort_event.wait(0.02):
|
||||
self.stop_move()
|
||||
return
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
class SimGalilState:
|
||||
"""Shared state of one simulated Galil controller (all axes, variables, threads)."""
|
||||
|
||||
N_AXES = 8
|
||||
|
||||
def __init__(self, host=None, port=None):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.lock = threading.RLock()
|
||||
self.axes = [_SimGalilAxis(i) for i in range(self.N_AXES)]
|
||||
self.variables = {
|
||||
"naxis": 0.0,
|
||||
"ndir": 0.0,
|
||||
"ntarget": 0.0,
|
||||
"movereq": 0.0,
|
||||
"mntmod": 0.0,
|
||||
"mntprgs": 0.0,
|
||||
"confirm": 0.0,
|
||||
"atconfir": 0.0,
|
||||
"getaprch": 0.0,
|
||||
"mntaprch": 0.0,
|
||||
"mntax": 0.0,
|
||||
"tracken": 0.0,
|
||||
"trackyct": 0.0,
|
||||
"trackzct": 0.0,
|
||||
"allaxref": 0.0,
|
||||
"axisref": 0.0,
|
||||
"frmmv": 0.0,
|
||||
}
|
||||
self.digital_inputs = {14: 1.0} # light curtain reads triggered by default
|
||||
self.digital_outputs = {9: 0.0, 15: 1.0} # gripper closed, lights on
|
||||
self.analog_inputs = {1: -1.6}
|
||||
self._thread_deadlines = {} # thread_id -> unix time until which thread is active
|
||||
self._transfer_thread = None
|
||||
self._transfer_abort = threading.Event()
|
||||
self._tracker_running = False
|
||||
self.confirm_steps = 3 # number of confirm handshakes per transfer routine
|
||||
|
||||
# --- axis / thread helpers -------------------------------------------------
|
||||
def axis(self, index: int) -> _SimGalilAxis:
|
||||
return self.axes[index]
|
||||
|
||||
def configure_axis(self, index: int, **kwargs):
|
||||
with self.lock:
|
||||
ax = self.axes[index]
|
||||
ax.connected = True
|
||||
if "stppermm" in kwargs and kwargs["stppermm"] is not None:
|
||||
ax.stppermm = float(kwargs["stppermm"])
|
||||
if "velocity" in kwargs and kwargs["velocity"] is not None:
|
||||
ax.speed_steps = float(kwargs["velocity"]) * ax.stppermm
|
||||
if "referenced" in kwargs and kwargs["referenced"] is not None:
|
||||
ax.referenced = bool(kwargs["referenced"])
|
||||
if "initial_position" in kwargs and kwargs["initial_position"] is not None:
|
||||
ax.pos_steps = float(kwargs["initial_position"]) * ax.stppermm
|
||||
if "analog_inputs" in kwargs and kwargs["analog_inputs"]:
|
||||
for chan, val in kwargs["analog_inputs"].items():
|
||||
self.analog_inputs[int(chan)] = float(val)
|
||||
if "digital_inputs" in kwargs and kwargs["digital_inputs"]:
|
||||
for chan, val in kwargs["digital_inputs"].items():
|
||||
self.digital_inputs[int(chan)] = float(val)
|
||||
if kwargs.get("limit_low_active") is not None:
|
||||
ax.limit_low_active = bool(kwargs["limit_low_active"])
|
||||
if kwargs.get("limit_high_active") is not None:
|
||||
ax.limit_high_active = bool(kwargs["limit_high_active"])
|
||||
|
||||
def thread_active(self, thread_id: int) -> bool:
|
||||
if thread_id == 0:
|
||||
if any(ax.is_moving() for ax in self.axes):
|
||||
return True
|
||||
if thread_id == 3:
|
||||
if self._transfer_thread is not None and self._transfer_thread.is_alive():
|
||||
return True
|
||||
if thread_id == 5:
|
||||
return self._tracker_running
|
||||
return time.time() < self._thread_deadlines.get(thread_id, 0.0)
|
||||
|
||||
def set_thread_active(self, thread_id: int, duration: float):
|
||||
self._thread_deadlines[thread_id] = time.time() + duration
|
||||
|
||||
def all_axes_referenced(self) -> bool:
|
||||
connected = [ax for ax in self.axes if ax.connected]
|
||||
if not connected:
|
||||
return False
|
||||
return all(ax.referenced for ax in connected)
|
||||
|
||||
# --- routines ---------------------------------------------------------------
|
||||
def stop_all(self):
|
||||
self._transfer_abort.set()
|
||||
for ax in self.axes:
|
||||
ax.stop_move()
|
||||
with self.lock:
|
||||
self.variables["mntmod"] = 0.0
|
||||
self.variables["mntprgs"] = 0.0
|
||||
self._tracker_running = False
|
||||
|
||||
def start_move_from_newpar(self):
|
||||
with self.lock:
|
||||
if self.variables.get("movereq", 0.0) != 1.0:
|
||||
return
|
||||
axis_index = int(self.variables.get("naxis", 0))
|
||||
target_mm = float(self.variables.get("ntarget", 0.0))
|
||||
self.variables["movereq"] = 0.0
|
||||
ax = self.axes[axis_index]
|
||||
if not (self.all_axes_referenced() and ax.referenced):
|
||||
return
|
||||
ax.motor_on = True
|
||||
ax.start_move(target_mm * ax.stppermm)
|
||||
|
||||
def reference_axis(self, axis_index: int, direction: float = 0.0):
|
||||
"""Simulate #FES / #FRM: short motion, then define zero at the reference."""
|
||||
ax = self.axes[axis_index]
|
||||
ax.motor_on = True
|
||||
ax.start_move(ax.position()) # ensure no stale motion
|
||||
# emulate the drive to the limit / reference with a short, visible motion
|
||||
self.set_thread_active(0, 0.5)
|
||||
deadline = time.time() + 0.5
|
||||
while time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
ax.pos_steps = 0.0
|
||||
ax.stop_move()
|
||||
ax.referenced = True
|
||||
if direction > 0:
|
||||
ax.limit_high_active = True
|
||||
elif direction < 0:
|
||||
ax.limit_low_active = True
|
||||
|
||||
def _confirm_handshake(self):
|
||||
with self.lock:
|
||||
if self.variables.get("atconfir", 0.0) == 1.0:
|
||||
return
|
||||
self.variables["confirm"] = -1.0
|
||||
while not self._transfer_abort.is_set():
|
||||
with self.lock:
|
||||
if self.variables.get("confirm", 0.0) >= 1.0:
|
||||
self.variables["confirm"] = 0.0
|
||||
return
|
||||
time.sleep(0.05)
|
||||
|
||||
def _transfer_routine(self, approach_var: str):
|
||||
"""Simulate #GRGET / #GRPUT on thread 3."""
|
||||
try:
|
||||
with self.lock:
|
||||
approach_mm = float(self.variables.get(approach_var, 0.0))
|
||||
mntax = int(self.variables.get("mntax", 0))
|
||||
ax = self.axes[mntax]
|
||||
initial_steps = ax.position()
|
||||
if approach_var == "getaprch":
|
||||
self._set_gripper(True) # JS#GROPEN before approaching
|
||||
ax.start_move(approach_mm * ax.stppermm)
|
||||
ax.wait_move(self._transfer_abort)
|
||||
for _ in range(self.confirm_steps):
|
||||
if self._transfer_abort.is_set():
|
||||
return
|
||||
self._set_gripper(not self._gripper_open())
|
||||
self._confirm_handshake()
|
||||
if self._transfer_abort.is_set():
|
||||
return
|
||||
ax.start_move(initial_steps - 0.5 * ax.stppermm)
|
||||
ax.wait_move(self._transfer_abort)
|
||||
# final gripper state: closed after #GRPUT, closed around sample after #GRGET
|
||||
self._set_gripper(False)
|
||||
finally:
|
||||
with self.lock:
|
||||
self.variables["mntprgs"] = 0.0
|
||||
self.variables[approach_var] = 0.0
|
||||
|
||||
def start_transfer(self, approach_var: str):
|
||||
with self.lock:
|
||||
if self.variables.get("mntmod", 0.0) != 1.0:
|
||||
logger.warning("[sim galil] transfer requested but system not in mount mode")
|
||||
return
|
||||
if self.variables.get("mntprgs", 0.0) == 1.0:
|
||||
logger.warning("[sim galil] transfer requested but a transfer is in progress")
|
||||
return
|
||||
if self.variables.get(approach_var, 0.0) == 0.0:
|
||||
logger.warning(f"[sim galil] transfer requested but {approach_var} not set")
|
||||
return
|
||||
self.variables["mntprgs"] = 1.0
|
||||
self._transfer_abort.clear()
|
||||
self._transfer_thread = threading.Thread(
|
||||
target=self._transfer_routine, args=(approach_var,), daemon=True
|
||||
)
|
||||
self._transfer_thread.start()
|
||||
|
||||
def _gripper_open(self) -> bool:
|
||||
return bool(self.digital_outputs.get(9, 0.0))
|
||||
|
||||
def _set_gripper(self, is_open: bool):
|
||||
self.digital_outputs[9] = 1.0 if is_open else 0.0
|
||||
|
||||
|
||||
class SimGalilSocket(SimSocketBase):
|
||||
"""Simulated socket implementing the fgalil.dmc / galil_micos_upr.dmc command set."""
|
||||
|
||||
state_cls = SimGalilState
|
||||
|
||||
def handle_command(self, line: str): # noqa: C901
|
||||
state: SimGalilState = self.state
|
||||
|
||||
if line.startswith("MG"):
|
||||
return self._handle_mg(line[2:].strip())
|
||||
|
||||
if line.startswith("XQ"):
|
||||
return self._handle_xq(line[2:].strip())
|
||||
|
||||
if line.startswith("SB") or line.startswith("CB"):
|
||||
channel = int(line[2:])
|
||||
state.digital_outputs[channel] = 1.0 if line.startswith("SB") else 0.0
|
||||
return ":"
|
||||
|
||||
# FUPR: absolute position in counts, e.g. "PAA=25600"
|
||||
match = re.match(r"^PA([A-H])=(-?[0-9.]+)$", line)
|
||||
if match:
|
||||
axis = ord(match.group(1).lower()) - 97
|
||||
state.variables[f"_fupr_target_{axis}"] = float(match.group(2))
|
||||
return ":"
|
||||
|
||||
# FUPR: begin motion, e.g. "BGA"
|
||||
match = re.match(r"^BG([A-H])$", line)
|
||||
if match:
|
||||
axis = ord(match.group(1).lower()) - 97
|
||||
target = state.variables.get(f"_fupr_target_{axis}", 0.0)
|
||||
ax = state.axis(axis)
|
||||
ax.motor_on = True
|
||||
ax.start_move(target)
|
||||
return ":"
|
||||
|
||||
# FUPR: position readback in counts, e.g. "TPA"
|
||||
match = re.match(r"^T[DP]([A-H])$", line)
|
||||
if match:
|
||||
axis = ord(match.group(1).lower()) - 97
|
||||
return f"{state.axis(axis).position():.4f}"
|
||||
|
||||
match = _ASSIGNMENT_RE.match(line)
|
||||
if match:
|
||||
return self._handle_assignment(*match.groups())
|
||||
|
||||
logger.warning(f"[sim galil] {self.host}:{self.port} unhandled command '{line}'")
|
||||
return ":"
|
||||
|
||||
def _handle_assignment(self, name: str, index: str | None, value: str):
|
||||
state: SimGalilState = self.state
|
||||
value = value.strip()
|
||||
expr = _STPPERMM_EXPR_RE.match(value)
|
||||
with state.lock:
|
||||
if name == "axspeed" and index is not None:
|
||||
ax = state.axis(int(index))
|
||||
if expr:
|
||||
ax.speed_steps = float(expr.group(1)) * state.axis(int(expr.group(2))).stppermm
|
||||
else:
|
||||
try:
|
||||
ax.speed_steps = float(value)
|
||||
except ValueError:
|
||||
logger.warning(f"[sim galil] cannot parse axspeed value '{value}'")
|
||||
return ":"
|
||||
try:
|
||||
parsed = float(value)
|
||||
except ValueError:
|
||||
parsed = value
|
||||
if index is not None:
|
||||
state.variables[f"{name}[{index}]"] = parsed
|
||||
else:
|
||||
state.variables[name] = parsed
|
||||
return ":"
|
||||
|
||||
def _handle_mg(self, arg: str):
|
||||
state: SimGalilState = self.state
|
||||
|
||||
if arg.startswith("_XQ"):
|
||||
thread_id = int(arg[3:])
|
||||
return "0.0000" if state.thread_active(thread_id) else "-1.0000"
|
||||
if arg.startswith("_BG"):
|
||||
axis = ord(arg[3:].lower()) - 97
|
||||
return f"{1.0 if state.axis(axis).is_moving() else 0.0:.4f}"
|
||||
if arg.startswith("bcklact["):
|
||||
return "0.0000"
|
||||
if arg.startswith("_MO"):
|
||||
axis = ord(arg[3:].lower()) - 97
|
||||
return f"{0.0 if state.axis(axis).motor_is_on() else 1.0:.4f}"
|
||||
if arg.startswith("_LR") or arg.startswith("_LF"):
|
||||
# "MG _LRA, _LFA" -> low and high limit switch (active low)
|
||||
axes = re.findall(r"_L[RF]([A-H])", arg)
|
||||
parts = []
|
||||
for token, axis_char in zip(re.findall(r"_L[RF]", arg), axes):
|
||||
ax = state.axis(ord(axis_char.lower()) - 97)
|
||||
active = ax.limit_low_active if token == "_LR" else ax.limit_high_active
|
||||
parts.append(f"{0.0 if active else 1.0:.4f}")
|
||||
return " ".join(parts)
|
||||
if arg.startswith("_TD"):
|
||||
axis = ord(arg[3:].lower()) - 97
|
||||
return f"{state.axis(axis).position():.4f}"
|
||||
if arg.startswith("_FL") or arg.startswith("_BL"):
|
||||
return "0.0000"
|
||||
if arg.startswith("@IN["):
|
||||
channel = int(arg[4:].split("]")[0])
|
||||
return f"{state.digital_inputs.get(channel, 0.0):.4f}"
|
||||
if arg.startswith("@OUT["):
|
||||
channel = int(arg[5:].split("]")[0])
|
||||
return f"{state.digital_outputs.get(channel, 0.0):.4f}"
|
||||
if arg.startswith("@AN["):
|
||||
channel = int(arg[4:].split("]")[0])
|
||||
return f"{state.analog_inputs.get(channel, 0.0):.4f}"
|
||||
if arg == "allaxref" or arg == "axisref":
|
||||
return f"{1.0 if state.all_axes_referenced() else 0.0:.4f}"
|
||||
if arg.startswith("axisref["):
|
||||
axis = int(arg.split("[")[1].split("]")[0])
|
||||
return f"{1.0 if state.axis(axis).referenced else 0.0:.4f}"
|
||||
if arg.startswith("folaxerr["):
|
||||
return "0.0000"
|
||||
if arg.startswith("stppermm["):
|
||||
axis = int(arg.split("[")[1].split("]")[0])
|
||||
return f"{state.axis(axis).stppermm:.4f}"
|
||||
with state.lock:
|
||||
if arg in state.variables:
|
||||
val = state.variables[arg]
|
||||
return f"{val:.4f}" if isinstance(val, float) else str(val)
|
||||
logger.warning(f"[sim galil] {self.host}:{self.port} unhandled MG '{arg}'")
|
||||
return "0.0000"
|
||||
|
||||
def _handle_xq(self, arg: str):
|
||||
state: SimGalilState = self.state
|
||||
routine = arg.split(",")[0]
|
||||
|
||||
if routine == "#NEWPAR":
|
||||
state.start_move_from_newpar()
|
||||
return ":"
|
||||
if routine in ("#FES", "#FRM"):
|
||||
with state.lock:
|
||||
axis_index = int(state.variables.get("naxis", 0))
|
||||
direction = float(state.variables.get("ndir", 0.0))
|
||||
threading.Thread(
|
||||
target=state.reference_axis, args=(axis_index, direction), daemon=True
|
||||
).start()
|
||||
return ":"
|
||||
if routine == "#REFAX": # FUPR
|
||||
threading.Thread(target=state.reference_axis, args=(0, 0.0), daemon=True).start()
|
||||
return ":"
|
||||
if routine == "#MOTON": # FUPR
|
||||
state.axis(0).motor_on = True
|
||||
state.axis(0).auto_motor_off = False
|
||||
return ":"
|
||||
if routine == "#STOP":
|
||||
state.stop_all()
|
||||
state.set_thread_active(1, 0.1)
|
||||
return ":"
|
||||
if routine == "#MNTMODE":
|
||||
with state.lock:
|
||||
state.variables["mntmod"] = 1.0
|
||||
return ":"
|
||||
if routine == "#POSMODE":
|
||||
with state.lock:
|
||||
if state.variables.get("mntprgs", 0.0) == 0.0:
|
||||
state.variables["mntmod"] = 0.0
|
||||
return ":"
|
||||
if routine == "#GRGET":
|
||||
state.start_transfer("getaprch")
|
||||
return ":"
|
||||
if routine == "#GRPUT":
|
||||
state.start_transfer("mntaprch")
|
||||
return ":"
|
||||
if routine == "#GROPEN":
|
||||
state.set_thread_active(4, 1.0)
|
||||
state._set_gripper(True)
|
||||
return ":"
|
||||
if routine == "#GRCLOS":
|
||||
state.set_thread_active(4, 1.0)
|
||||
state._set_gripper(False)
|
||||
return ":"
|
||||
if routine == "#Tracker":
|
||||
with state.lock:
|
||||
if state.variables.get("tracken", 0.0) == 1.0 and state.all_axes_referenced():
|
||||
state._tracker_running = True
|
||||
return ":"
|
||||
logger.warning(f"[sim galil] {self.host}:{self.port} unhandled XQ '{arg}'")
|
||||
return ":"
|
||||
|
||||
|
||||
def _pop_sim_kwargs(kwargs: dict) -> dict:
|
||||
return {
|
||||
"initial_position": kwargs.pop("sim_initial_position", None),
|
||||
"velocity": kwargs.pop("sim_velocity", None),
|
||||
"stppermm": kwargs.pop("sim_stppermm", None),
|
||||
"referenced": kwargs.pop("sim_referenced", True),
|
||||
"analog_inputs": kwargs.pop("sim_analog_inputs", None),
|
||||
"digital_inputs": kwargs.pop("sim_digital_inputs", None),
|
||||
"limit_low_active": kwargs.pop("sim_limit_low_active", None),
|
||||
"limit_high_active": kwargs.pop("sim_limit_high_active", None),
|
||||
}
|
||||
|
||||
|
||||
class SimFlomniGalilMotor(FlomniGalilMotor):
|
||||
"""FlomniGalilMotor connected to the simulated Galil controller."""
|
||||
|
||||
def __init__(self, axis_Id, *args, **kwargs):
|
||||
sim_kwargs = _pop_sim_kwargs(kwargs)
|
||||
kwargs["socket_cls"] = SimGalilSocket
|
||||
super().__init__(axis_Id, *args, **kwargs)
|
||||
state = SimStateRegistry.get(SimGalilState, kwargs.get("host"), kwargs.get("port"))
|
||||
if sim_kwargs["initial_position"] is not None:
|
||||
sim_kwargs["initial_position"] = sim_kwargs["initial_position"] * self.sign
|
||||
state.configure_axis(self.axis_Id_numeric, **sim_kwargs)
|
||||
|
||||
|
||||
class SimFuprGalilMotor(FuprGalilMotor):
|
||||
"""FuprGalilMotor (rotation stage) connected to the simulated Galil controller."""
|
||||
|
||||
def __init__(self, axis_Id, *args, **kwargs):
|
||||
sim_kwargs = _pop_sim_kwargs(kwargs)
|
||||
kwargs["socket_cls"] = SimGalilSocket
|
||||
super().__init__(axis_Id, *args, **kwargs)
|
||||
state = SimStateRegistry.get(SimGalilState, kwargs.get("host"), kwargs.get("port"))
|
||||
sim_kwargs["stppermm"] = self.MOTOR_RESOLUTION
|
||||
state.axis(self.axis_Id_numeric).auto_motor_off = False
|
||||
if sim_kwargs["velocity"] is None:
|
||||
sim_kwargs["velocity"] = 25.0 # deg/s, SPA=resol*25 after referencing
|
||||
if sim_kwargs["initial_position"] is not None:
|
||||
sim_kwargs["initial_position"] = sim_kwargs["initial_position"] * self.sign
|
||||
state.configure_axis(self.axis_Id_numeric, **sim_kwargs)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Simulated flOMNI RT (Orchestra) communication server.
|
||||
|
||||
Implements the TCP command set of `flOMNI/FeedbackLoop/tcpip_server/CommunicationServer.cpp`
|
||||
used by `RtFlomniController`, `RtFlomniMotor` and `RtFlomniFlyer`. Reply formats and, in
|
||||
particular, which commands are reply-less (`pa`, `sc`, `l0/l1/l3`, `a<val>`, `T0/T1`,
|
||||
`v0/v1`, emitter setters) follow the real server to keep the command/reply stream in sync.
|
||||
|
||||
Simulated behavior:
|
||||
- interferometer feedback and laser tracker as simple boolean states; tracker reports
|
||||
on-target beam positions and healthy intensity when enabled
|
||||
- position scans (`s...`, `sd`, `sr`, `r<i>`) advance with a configurable per-point dwell
|
||||
time so that scan durations, progress updates and the flyer readout behave realistically
|
||||
- sampled data rows contain the target positions plus small gaussian noise
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from csaxs_bec.devices.omny.rt.rt_flomni_ophyd import RtFlomniFlyer, RtFlomniMotor
|
||||
from csaxs_bec.devices.sim.sim_socket import SimSocketBase, SimStateRegistry
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_POSITION_NOISE_UM = 0.002 # ~2 nm rms positioning noise in the sampled data
|
||||
|
||||
|
||||
class SimRtFlomniState:
|
||||
"""Shared state of the simulated Orchestra server (one per host:port)."""
|
||||
|
||||
def __init__(self, host=None, port=None):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.lock = threading.RLock()
|
||||
self.targets = [0.0, 0.0, 0.0]
|
||||
self.feedback_running = False
|
||||
self.tracker_enabled = False
|
||||
self.angle_rad = 0.0
|
||||
self.pid_x_voltage = -0.062
|
||||
self.emitter = {"ty": 0.0, "tz": 0.0, "thr_laser": 1000.0, "psd_low": 100.0}
|
||||
self.ssi_signal = 12000.0
|
||||
self.tracker_intensity = 30000.0
|
||||
self.point_dwell_s = 0.02
|
||||
self.scan_positions = []
|
||||
self.scan_mode = 0
|
||||
self.scan_current = 0
|
||||
self._scan_thread = None
|
||||
self._scan_abort = threading.Event()
|
||||
|
||||
def configure(self, **kwargs):
|
||||
with self.lock:
|
||||
if kwargs.get("point_dwell_s") is not None:
|
||||
self.point_dwell_s = float(kwargs["point_dwell_s"])
|
||||
if kwargs.get("pid_x_voltage") is not None:
|
||||
self.pid_x_voltage = float(kwargs["pid_x_voltage"])
|
||||
if kwargs.get("ssi_signal") is not None:
|
||||
self.ssi_signal = float(kwargs["ssi_signal"])
|
||||
|
||||
# --- scan handling -----------------------------------------------------------
|
||||
def clear_scan(self):
|
||||
self._scan_abort.set()
|
||||
if self._scan_thread is not None and self._scan_thread.is_alive():
|
||||
self._scan_thread.join(timeout=2)
|
||||
with self.lock:
|
||||
self.scan_positions = []
|
||||
self.scan_mode = 0
|
||||
self.scan_current = 0
|
||||
self.targets = [0.0, 0.0, self.targets[2]]
|
||||
|
||||
def add_scan_position(self, pos):
|
||||
with self.lock:
|
||||
self.scan_positions.append(pos)
|
||||
return len(self.scan_positions)
|
||||
|
||||
def start_scan(self) -> int:
|
||||
with self.lock:
|
||||
num_pos = len(self.scan_positions)
|
||||
if num_pos == 0 or self.scan_mode > 0:
|
||||
return num_pos
|
||||
self.scan_mode = 3
|
||||
self.scan_current = 0
|
||||
self._scan_abort.clear()
|
||||
self._scan_thread = threading.Thread(target=self._scan_loop, daemon=True)
|
||||
self._scan_thread.start()
|
||||
return num_pos
|
||||
|
||||
def _scan_loop(self):
|
||||
with self.lock:
|
||||
self.scan_mode = 2
|
||||
num_pos = len(self.scan_positions)
|
||||
for index in range(num_pos):
|
||||
if self._scan_abort.wait(self.point_dwell_s):
|
||||
return
|
||||
with self.lock:
|
||||
self.scan_current = index + 1
|
||||
self.targets = list(self.scan_positions[index])
|
||||
with self.lock:
|
||||
self.scan_mode = 0
|
||||
|
||||
def scan_status(self):
|
||||
with self.lock:
|
||||
return (self.scan_mode, len(self.scan_positions), self.scan_current)
|
||||
|
||||
def sample_row(self, index: int) -> str:
|
||||
with self.lock:
|
||||
if 0 <= index < len(self.scan_positions):
|
||||
target = self.scan_positions[index]
|
||||
else:
|
||||
target = self.targets
|
||||
noise = _POSITION_NOISE_UM
|
||||
avg_x = target[0] + random.gauss(0.0, noise)
|
||||
avg_y = target[1] + random.gauss(0.0, noise)
|
||||
rotz = random.gauss(0.0, 0.1)
|
||||
return (
|
||||
f"{index}, 100, {target[0]:.5f}, {avg_x:.5f}, {abs(random.gauss(noise, noise / 4)):.5f},"
|
||||
f" {target[1]:.5f}, {avg_y:.5f}, {abs(random.gauss(noise, noise / 4)):.5f},"
|
||||
f" {rotz:.5f}, {abs(random.gauss(0.1, 0.02)):.5f}\n"
|
||||
)
|
||||
|
||||
|
||||
class SimRtFlomniSocket(SimSocketBase):
|
||||
"""Simulated socket implementing the flOMNI Orchestra communication server protocol."""
|
||||
|
||||
state_cls = SimRtFlomniState
|
||||
|
||||
def handle_command(self, line: str): # noqa: C901
|
||||
state: SimRtFlomniState = self.state
|
||||
cmd, args = line[0], line[1:]
|
||||
|
||||
if cmd == "l":
|
||||
if args.startswith("0"):
|
||||
state.feedback_running = False
|
||||
return None
|
||||
if args.startswith("1") or args.startswith("3"):
|
||||
state.feedback_running = True
|
||||
return None
|
||||
if args.startswith("2"):
|
||||
return f"{0 if state.feedback_running else 1}\n"
|
||||
return None
|
||||
|
||||
if cmd == "p":
|
||||
if args.startswith("r"):
|
||||
targets = state.targets
|
||||
return f"{targets[0]:.5f},{targets[1]:.5f},{targets[2]:.5f}\n"
|
||||
if args.startswith("a"):
|
||||
axis_str, value_str = args[1:].split(",")
|
||||
with state.lock:
|
||||
state.targets[int(float(axis_str))] = float(value_str)
|
||||
return None
|
||||
values = [float(val) for val in args.split(",")]
|
||||
with state.lock:
|
||||
state.targets = values[:3]
|
||||
return None
|
||||
|
||||
if cmd == "s":
|
||||
if args.startswith("c"):
|
||||
state.clear_scan()
|
||||
return None
|
||||
if args.startswith("r"):
|
||||
mode, num_pos, current = state.scan_status()
|
||||
return f"{mode:.5f},{num_pos:.5f},{current:.5f}\n"
|
||||
if args.startswith("d"):
|
||||
num_pos = state.start_scan()
|
||||
return f"Scan started {num_pos:.0f} positions.\n"
|
||||
if args.startswith("h"):
|
||||
state.clear_scan()
|
||||
return "0.00000, 0.00000, 0.00000\n"
|
||||
count = state.add_scan_position([float(val) for val in args.split(",")])
|
||||
return f"{count:.0f}\n"
|
||||
|
||||
if cmd == "r":
|
||||
return state.sample_row(int(args))
|
||||
|
||||
if cmd == "a":
|
||||
if args.startswith("r"):
|
||||
return f"{state.angle_rad:f}\n"
|
||||
state.angle_rad = float(args)
|
||||
return None
|
||||
|
||||
if cmd == "T":
|
||||
if args.startswith("s"):
|
||||
return self._tracker_status()
|
||||
state.tracker_enabled = args.startswith("1")
|
||||
return None
|
||||
|
||||
if cmd == "e":
|
||||
if args[:1] in ("y", "z", "t", "l"):
|
||||
key = {"y": "ty", "z": "tz", "t": "thr_laser", "l": "psd_low"}[args[0]]
|
||||
with state.lock:
|
||||
state.emitter[key] = float(args[1:])
|
||||
return None
|
||||
emitter = state.emitter
|
||||
return (
|
||||
f"{emitter['ty']:.5f},{emitter['tz']:.5f},"
|
||||
f"{emitter['thr_laser']:.2f},{emitter['psd_low']:.5f}\n"
|
||||
)
|
||||
|
||||
if cmd == "g":
|
||||
return f"{state.pid_x_voltage + random.gauss(0.0, 0.005):f}\n"
|
||||
if cmd == "G":
|
||||
return f"{random.gauss(0.0, 0.005):f}\n"
|
||||
if cmd == "y":
|
||||
return "3.000000\n"
|
||||
if cmd == "w":
|
||||
return "32.000000\n"
|
||||
if cmd == "j":
|
||||
return f"{state.ssi_signal:f}\n"
|
||||
if cmd == "k":
|
||||
axis = int(args)
|
||||
return f"{state.targets[axis] if axis < 3 else 0.0:f}\n"
|
||||
if cmd == "v":
|
||||
return None
|
||||
if cmd == "d":
|
||||
return None
|
||||
if cmd == "o":
|
||||
# not present in the current real server; provided for API completeness
|
||||
return "1\n"
|
||||
if cmd == "t":
|
||||
return "1"
|
||||
|
||||
logger.warning(f"[sim rt] {self.host}:{self.port} unhandled command '{line}'")
|
||||
return None
|
||||
|
||||
def _tracker_status(self) -> str:
|
||||
state: SimRtFlomniState = self.state
|
||||
enabled = 1.0 if state.tracker_enabled else 0.0
|
||||
intensity = state.tracker_intensity
|
||||
threshold = state.emitter["thr_laser"]
|
||||
target_z = state.emitter["tz"]
|
||||
target_y = state.emitter["ty"]
|
||||
return (
|
||||
f"{target_z:.2f},{target_z:.2f},{intensity:.2f},{threshold:.2f},{5.0:.2f},"
|
||||
f"{target_y:.2f},{target_y:.2f},{intensity:.2f},{threshold:.2f},{5.0:.2f},"
|
||||
f"{enabled:.0f}\n"
|
||||
)
|
||||
|
||||
|
||||
def _pop_sim_kwargs(kwargs: dict) -> dict:
|
||||
return {
|
||||
"point_dwell_s": kwargs.pop("sim_point_dwell_s", None),
|
||||
"pid_x_voltage": kwargs.pop("sim_pid_x_voltage", None),
|
||||
"ssi_signal": kwargs.pop("sim_ssi_signal", None),
|
||||
}
|
||||
|
||||
|
||||
class SimRtFlomniMotor(RtFlomniMotor):
|
||||
"""RtFlomniMotor connected to the simulated Orchestra server."""
|
||||
|
||||
def __init__(self, axis_Id, *args, **kwargs):
|
||||
sim_kwargs = _pop_sim_kwargs(kwargs)
|
||||
kwargs["socket_cls"] = SimRtFlomniSocket
|
||||
super().__init__(axis_Id, *args, **kwargs)
|
||||
SimStateRegistry.get(SimRtFlomniState, kwargs.get("host"), kwargs.get("port")).configure(
|
||||
**sim_kwargs
|
||||
)
|
||||
|
||||
|
||||
class SimRtFlomniFlyer(RtFlomniFlyer):
|
||||
"""RtFlomniFlyer connected to the simulated Orchestra server."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
sim_kwargs = _pop_sim_kwargs(kwargs)
|
||||
kwargs["socket_cls"] = SimRtFlomniSocket
|
||||
super().__init__(*args, **kwargs)
|
||||
SimStateRegistry.get(SimRtFlomniState, kwargs.get("host"), kwargs.get("port")).configure(
|
||||
**sim_kwargs
|
||||
)
|
||||
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Simulated Smaract MCS controller for the flOMNI OSA stages (fosax/fosay/fosaz).
|
||||
|
||||
Implements the ASCII command subset used by `SmaractController` / `SmaractMotor`.
|
||||
Positions are handled in nanometers internally, matching the MCS protocol. Motion is
|
||||
simulated with a constant closed-loop speed; `SCLS` updates it like on the real system.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from csaxs_bec.devices.sim.sim_socket import SimSocketBase, SimStateRegistry
|
||||
from csaxs_bec.devices.smaract.smaract_ophyd import SmaractMotor
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_STATUS_STOPPED = 0
|
||||
_STATUS_HOLDING = 3
|
||||
_STATUS_TARGETING = 4
|
||||
|
||||
_OPEN_LOOP_NM_PER_STEP = 100.0 # coarse approximation for MST bursts
|
||||
|
||||
|
||||
class _SimSmaractAxis:
|
||||
def __init__(self, index: int):
|
||||
self.index = index
|
||||
self.connected = False
|
||||
self.referenced = True
|
||||
self.pos_nm = 0.0
|
||||
self.speed_nm_s = 1.0e6 # 1 mm/s default
|
||||
self.low_limit_nm = -1.0e9
|
||||
self.high_limit_nm = 1.0e9
|
||||
self._move_start_nm = 0.0
|
||||
self._move_target_nm = 0.0
|
||||
self._move_t0 = 0.0
|
||||
self._move_t1 = 0.0
|
||||
|
||||
def position(self) -> float:
|
||||
now = time.time()
|
||||
if now >= self._move_t1:
|
||||
return self.pos_nm
|
||||
frac = (now - self._move_t0) / (self._move_t1 - self._move_t0)
|
||||
return self._move_start_nm + frac * (self._move_target_nm - self._move_start_nm)
|
||||
|
||||
def is_moving(self) -> bool:
|
||||
return time.time() < self._move_t1
|
||||
|
||||
def start_move(self, target_nm: float):
|
||||
speed = max(self.speed_nm_s, 1.0)
|
||||
current = self.position()
|
||||
duration = abs(target_nm - current) / speed
|
||||
self._move_start_nm = current
|
||||
self._move_target_nm = target_nm
|
||||
self._move_t0 = time.time()
|
||||
self._move_t1 = self._move_t0 + duration
|
||||
self.pos_nm = target_nm
|
||||
|
||||
def stop_move(self):
|
||||
self.pos_nm = self.position()
|
||||
self._move_t1 = 0.0
|
||||
|
||||
|
||||
class SimSmaractState:
|
||||
"""Shared state of one simulated Smaract MCS controller."""
|
||||
|
||||
N_AXES = 9
|
||||
|
||||
def __init__(self, host=None, port=None):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.lock = threading.RLock()
|
||||
self.axes = [_SimSmaractAxis(i) for i in range(self.N_AXES)]
|
||||
|
||||
def axis(self, index: int) -> _SimSmaractAxis:
|
||||
return self.axes[index]
|
||||
|
||||
def configure_axis(self, index: int, **kwargs):
|
||||
with self.lock:
|
||||
ax = self.axes[index]
|
||||
ax.connected = True
|
||||
if kwargs.get("velocity") is not None:
|
||||
ax.speed_nm_s = float(kwargs["velocity"]) * 1.0e6 # mm/s -> nm/s
|
||||
if kwargs.get("referenced") is not None:
|
||||
ax.referenced = bool(kwargs["referenced"])
|
||||
if kwargs.get("initial_position") is not None:
|
||||
ax.pos_nm = float(kwargs["initial_position"]) * 1.0e6 # mm -> nm
|
||||
|
||||
|
||||
class SimSmaractSocket(SimSocketBase):
|
||||
"""Simulated socket implementing the Smaract MCS ASCII protocol subset."""
|
||||
|
||||
state_cls = SimSmaractState
|
||||
|
||||
def handle_command(self, line: str): # noqa: C901
|
||||
state: SimSmaractState = self.state
|
||||
cmd = line.lstrip(":").strip()
|
||||
|
||||
match = re.match(r"^GS(\d+)$", cmd)
|
||||
if match:
|
||||
ax = state.axis(int(match.group(1)))
|
||||
status = _STATUS_TARGETING if ax.is_moving() else _STATUS_HOLDING
|
||||
return f":S{match.group(1)},{status}\n"
|
||||
|
||||
match = re.match(r"^GP(\d+)$", cmd)
|
||||
if match:
|
||||
ax = state.axis(int(match.group(1)))
|
||||
return f":P{match.group(1)},{ax.position():.0f}\n"
|
||||
|
||||
match = re.match(r"^GPPK(\d+)$", cmd)
|
||||
if match:
|
||||
ax = state.axis(int(match.group(1)))
|
||||
return f":PPK{match.group(1)},{1 if ax.referenced else 0}\n"
|
||||
|
||||
match = re.match(r"^MPA(\d+),(-?\d+),(\d+)$", cmd)
|
||||
if match:
|
||||
ax = state.axis(int(match.group(1)))
|
||||
ax.start_move(float(match.group(2)))
|
||||
return f":E{match.group(1)},0\n"
|
||||
|
||||
match = re.match(r"^MPR(\d+),(-?\d+),(\d+)$", cmd)
|
||||
if match:
|
||||
ax = state.axis(int(match.group(1)))
|
||||
ax.start_move(ax.position() + float(match.group(2)))
|
||||
return f":E{match.group(1)},0\n"
|
||||
|
||||
match = re.match(r"^MST(\d+),(-?\d+),(\d+),(\d+)$", cmd)
|
||||
if match:
|
||||
ax = state.axis(int(match.group(1)))
|
||||
ax.start_move(ax.position() + float(match.group(2)) * _OPEN_LOOP_NM_PER_STEP)
|
||||
return f":E{match.group(1)},0\n"
|
||||
|
||||
match = re.match(r"^FRM(\d+),(-?\d+),(\d+),(\d+)$", cmd)
|
||||
if match:
|
||||
ax = state.axis(int(match.group(1)))
|
||||
ax.stop_move()
|
||||
if int(match.group(4)): # autoZero
|
||||
ax.pos_nm = 0.0
|
||||
ax.referenced = True
|
||||
return f":E{match.group(1)},0\n"
|
||||
|
||||
match = re.match(r"^S(\d+)$", cmd)
|
||||
if match:
|
||||
state.axis(int(match.group(1))).stop_move()
|
||||
return f":E{match.group(1)},0\n"
|
||||
|
||||
match = re.match(r"^SCLS(\d+),(\d+)$", cmd)
|
||||
if match:
|
||||
state.axis(int(match.group(1))).speed_nm_s = float(match.group(2))
|
||||
return f":E{match.group(1)},0\n"
|
||||
|
||||
match = re.match(r"^GCLS(\d+)$", cmd)
|
||||
if match:
|
||||
ax = state.axis(int(match.group(1)))
|
||||
return f":CLS{match.group(1)},{ax.speed_nm_s:.0f}\n"
|
||||
|
||||
match = re.match(r"^GPL(\d+)$", cmd)
|
||||
if match:
|
||||
ax = state.axis(int(match.group(1)))
|
||||
return f":GPL{match.group(1)},{ax.low_limit_nm:.0f},{ax.high_limit_nm:.0f}\n"
|
||||
|
||||
match = re.match(r"^SPL(\d+),(-?\d+),(-?\d+)$", cmd)
|
||||
if match:
|
||||
ax = state.axis(int(match.group(1)))
|
||||
ax.low_limit_nm = float(match.group(2))
|
||||
ax.high_limit_nm = float(match.group(3))
|
||||
return f":E{match.group(1)},0\n"
|
||||
|
||||
match = re.match(r"^GST(\d+)$", cmd)
|
||||
if match:
|
||||
return f":ST{match.group(1)},1\n"
|
||||
|
||||
if cmd == "GCM":
|
||||
return ":CM0\n"
|
||||
if cmd == "GNC":
|
||||
return f":N{SimSmaractState.N_AXES}\n"
|
||||
if cmd == "GSI":
|
||||
return ":ID4242\n"
|
||||
if cmd == "GIV":
|
||||
return ":IV1,0,0\n"
|
||||
if cmd.startswith("SHE"):
|
||||
return ":E-1,0\n"
|
||||
if cmd == "R":
|
||||
return ":E-1,0\n"
|
||||
|
||||
logger.warning(f"[sim smaract] {self.host}:{self.port} unhandled command '{cmd}'")
|
||||
return ":E-1,0\n"
|
||||
|
||||
|
||||
class SimSmaractMotor(SmaractMotor):
|
||||
"""SmaractMotor connected to the simulated MCS controller."""
|
||||
|
||||
def __init__(self, axis_Id, *args, **kwargs):
|
||||
sim_kwargs = {
|
||||
"initial_position": kwargs.pop("sim_initial_position", None),
|
||||
"velocity": kwargs.pop("sim_velocity", None),
|
||||
"referenced": kwargs.pop("sim_referenced", True),
|
||||
}
|
||||
kwargs["socket_cls"] = SimSmaractSocket
|
||||
super().__init__(axis_Id, *args, **kwargs)
|
||||
state = SimStateRegistry.get(SimSmaractState, kwargs.get("host"), kwargs.get("port"))
|
||||
if sim_kwargs["initial_position"] is not None:
|
||||
sim_kwargs["initial_position"] = sim_kwargs["initial_position"] * self.sign
|
||||
state.configure_axis(self.axis_Id_numeric, **sim_kwargs)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Core infrastructure for simulated controller sockets.
|
||||
|
||||
The socket-based controllers in ophyd_devices (`Controller.on`) create their transport via
|
||||
``self._socket_cls(host=..., port=...)``. Any class exposing the `SocketIO` interface
|
||||
(`put`, `receive`, `open`, `close`, `host`, `port`, `is_open`) can be injected instead of a
|
||||
real TCP socket. The simulated sockets in this package implement the wire protocols of the
|
||||
flOMNI hardware (Galil DMC, Smaract MCS, flOMNI Orchestra communication server) as small
|
||||
state machines, so that the *real* device and controller classes can be used unchanged.
|
||||
|
||||
Commands are dispatched synchronously within `put()`; replies are appended to an internal
|
||||
queue that `receive()` drains. Commands that do not produce a reply on the real hardware
|
||||
must not enqueue one here, otherwise the command/reply stream desynchronizes.
|
||||
|
||||
Simulation state is shared per (host, port) via a registry, mirroring the singleton
|
||||
behavior of `ophyd_devices.utils.controller.Controller`. This allows the simulated device
|
||||
classes to seed axis parameters (initial position, velocity, resolution) at construction
|
||||
time, before the controller opens its socket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class SimStateRegistry:
|
||||
"""Registry of simulation states, shared per (host, port) like the controllers."""
|
||||
|
||||
_states = {}
|
||||
_lock = threading.RLock()
|
||||
|
||||
@classmethod
|
||||
def get(cls, state_cls, host, port):
|
||||
"""Return the simulation state for (host, port), creating it if needed."""
|
||||
key = (state_cls, str(host), int(port))
|
||||
with cls._lock:
|
||||
if key not in cls._states:
|
||||
cls._states[key] = state_cls(host=host, port=port)
|
||||
return cls._states[key]
|
||||
|
||||
@classmethod
|
||||
def reset(cls):
|
||||
"""Clear all simulation states (mainly for tests)."""
|
||||
with cls._lock:
|
||||
cls._states.clear()
|
||||
|
||||
|
||||
class SimSocketBase:
|
||||
"""
|
||||
Drop-in replacement for `ophyd_devices.utils.socket.SocketIO`.
|
||||
|
||||
Children must set `state_cls` and implement `handle_command(line) -> str | None`.
|
||||
A returned string is encoded and appended to the reply queue; None means no reply.
|
||||
"""
|
||||
|
||||
state_cls = None
|
||||
|
||||
def __init__(self, host, port, socket_timeout: int = 2):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.socket_timeout = socket_timeout
|
||||
self.is_open = False
|
||||
self._recv_buffer = []
|
||||
self._cmd_buffer = b""
|
||||
self._lock = threading.RLock()
|
||||
self.state = SimStateRegistry.get(self.state_cls, host, port)
|
||||
|
||||
# --- SocketIO interface -------------------------------------------------
|
||||
def open(self, timeout: int = 10):
|
||||
logger.info(f"[sim] Connecting to simulated controller {self.host}:{self.port}.")
|
||||
self.is_open = True
|
||||
|
||||
def connect(self, timeout: int = 10):
|
||||
self.is_open = True
|
||||
|
||||
def close(self):
|
||||
self.is_open = False
|
||||
|
||||
def put(self, msg: bytes):
|
||||
with self._lock:
|
||||
self._cmd_buffer += msg
|
||||
# both \r (Galil) and \n (RT, Smaract) terminate commands
|
||||
normalized = self._cmd_buffer.replace(b"\r", b"\n")
|
||||
*lines, rest = normalized.split(b"\n")
|
||||
self._cmd_buffer = rest
|
||||
for line in lines:
|
||||
line = line.decode(errors="replace").strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
reply = self.handle_command(line)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(f"[sim] {self.host}:{self.port} failed to handle '{line}'")
|
||||
reply = None
|
||||
if reply is not None:
|
||||
self._recv_buffer.append(reply.encode())
|
||||
|
||||
def receive(self, buffer_length=1024):
|
||||
with self._lock:
|
||||
if self._recv_buffer:
|
||||
return self._recv_buffer.pop(0)
|
||||
return b""
|
||||
|
||||
# --- to be implemented by children ---------------------------------------
|
||||
def handle_command(self, line: str):
|
||||
raise NotImplementedError
|
||||
Reference in New Issue
Block a user