From d38f72845ae974d1926efb111b9a4933e9d4b137 Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 10 Jul 2026 15:19:52 +0200 Subject: [PATCH 1/5] 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. --- .../simulated_omny/simulated_flomni.yaml | 574 ++++++++++++++++++ csaxs_bec/devices/sim/sim_cameras.py | 100 +++ csaxs_bec/devices/sim/sim_galil.py | 513 ++++++++++++++++ csaxs_bec/devices/sim/sim_rt_flomni.py | 272 +++++++++ csaxs_bec/devices/sim/sim_smaract.py | 208 +++++++ csaxs_bec/devices/sim/sim_socket.py | 110 ++++ 6 files changed, 1777 insertions(+) create mode 100644 csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml create mode 100644 csaxs_bec/devices/sim/sim_cameras.py create mode 100644 csaxs_bec/devices/sim/sim_galil.py create mode 100644 csaxs_bec/devices/sim/sim_rt_flomni.py create mode 100644 csaxs_bec/devices/sim/sim_smaract.py create mode 100644 csaxs_bec/devices/sim/sim_socket.py diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml new file mode 100644 index 0000000..c57eadc --- /dev/null +++ b/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml @@ -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 diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py new file mode 100644 index 0000000..6219915 --- /dev/null +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -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 diff --git a/csaxs_bec/devices/sim/sim_galil.py b/csaxs_bec/devices/sim/sim_galil.py new file mode 100644 index 0000000..456786b --- /dev/null +++ b/csaxs_bec/devices/sim/sim_galil.py @@ -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) diff --git a/csaxs_bec/devices/sim/sim_rt_flomni.py b/csaxs_bec/devices/sim/sim_rt_flomni.py new file mode 100644 index 0000000..292bfba --- /dev/null +++ b/csaxs_bec/devices/sim/sim_rt_flomni.py @@ -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`, `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`) 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 + ) diff --git a/csaxs_bec/devices/sim/sim_smaract.py b/csaxs_bec/devices/sim/sim_smaract.py new file mode 100644 index 0000000..192c4f8 --- /dev/null +++ b/csaxs_bec/devices/sim/sim_smaract.py @@ -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) diff --git a/csaxs_bec/devices/sim/sim_socket.py b/csaxs_bec/devices/sim/sim_socket.py new file mode 100644 index 0000000..a52adaa --- /dev/null +++ b/csaxs_bec/devices/sim/sim_socket.py @@ -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 -- 2.54.0 From 97dc31b0c9c090a878b42b3f3b47b50b2bf44683 Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 10 Jul 2026 15:46:47 +0200 Subject: [PATCH 2/5] fixes/version2 sim devices s Please enter the commit message for your changes. Lines starting --- .../simulated_omny/simulated_flomni.yaml | 2 +- .../epics/delay_generator_csaxs/ddg_1.py | 3 +- csaxs_bec/devices/sim/sim_cameras.py | 54 ++- csaxs_bec/devices/sim/sim_galil.py | 150 +++++-- csaxs_bec/devices/sim/sim_rt_flomni.py | 84 +++- csaxs_bec/devices/sim/sim_smaract.py | 56 ++- tests/sim_flomni_harness.py | 378 ++++++++++++++++++ 7 files changed, 655 insertions(+), 72 deletions(-) create mode 100644 tests/sim_flomni_harness.py diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml index c57eadc..2890aed 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml @@ -295,7 +295,7 @@ ftransy: sim_velocity: 5 sim_initial_position: 0 sim_analog_inputs: - 1: -1.6 + "1": -1.6 enabled: true onFailure: buffer readOnly: false diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index 05d1054..ad05299 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -37,7 +37,7 @@ import traceback from typing import TYPE_CHECKING from bec_lib.logger import bec_logger -from bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInfo +#from bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInfo from ophyd import Component as Cpt from ophyd import EpicsSignalRO, Kind from ophyd_devices import CompareStatus, DeviceStatus, StatusBase, TransitionStatus @@ -60,6 +60,7 @@ from csaxs_bec.devices.utils.utils import fetch_scan_info if TYPE_CHECKING: # pragma: no cover from bec_lib.devicemanager import DeviceManagerBase, ScanInfo + from bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInfo from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import MCSCardCSAXS diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py index 6219915..31cdfb4 100644 --- a/csaxs_bec/devices/sim/sim_cameras.py +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -40,11 +40,25 @@ def make_test_pattern(height: int, width: int, rgb: bool) -> np.ndarray: class SimWebcamViewer(WebcamViewer): - """WebcamViewer serving a static synthetic frame instead of an MJPEG stream.""" + """WebcamViewer serving a static synthetic frame instead of an MJPEG stream. - def __init__(self, url: str, name: str, sim_shape=(480, 640), **kwargs) -> None: + The signature lists all deviceConfig keys explicitly; the BEC device server only + passes config keys present in inspect.signature(dev_cls). + """ + + def __init__( + self, + url: str = "http://simulated", + name: str = "", + num_rotation_90=0, + transpose=False, + 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) + super().__init__( + url=url, name=name, num_rotation_90=num_rotation_90, transpose=transpose, **kwargs + ) def _update_loop(self) -> None: while not self._shutdown_event.is_set(): @@ -93,8 +107,34 @@ class _SimIDSBackend: 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) + def __init__( + self, + *, + name: str, + camera_id: int = 0, + prefix: str = "", + scan_info=None, + m_n_colormode=1, + bits_per_pixel=24, + live_mode: bool = False, + num_rotation_90: int = 0, + transpose: bool = False, + force_monochrome: bool = False, + sim_shape=(1024, 1280), + **kwargs, + ): + super().__init__( + name=name, + camera_id=camera_id, + prefix=prefix, + scan_info=scan_info, + m_n_colormode=m_n_colormode, + bits_per_pixel=bits_per_pixel, + live_mode=live_mode, + num_rotation_90=num_rotation_90, + transpose=transpose, + force_monochrome=force_monochrome, + **kwargs, + ) + self.cam = _SimIDSBackend(int(sim_shape[1]), int(sim_shape[0]), rgb=not force_monochrome) self.cam.force_monochrome = self._force_monochrome diff --git a/csaxs_bec/devices/sim/sim_galil.py b/csaxs_bec/devices/sim/sim_galil.py index 456786b..1474287 100644 --- a/csaxs_bec/devices/sim/sim_galil.py +++ b/csaxs_bec/devices/sim/sim_galil.py @@ -470,44 +470,136 @@ class SimGalilSocket(SimSocketBase): return ":" -def _pop_sim_kwargs(kwargs: dict) -> dict: +def _sim_state_kwargs( + sim_initial_position, + sim_velocity, + sim_stppermm, + sim_referenced, + sim_analog_inputs, + sim_digital_inputs, + sim_limit_low_active, + sim_limit_high_active, + sign, +) -> dict: + initial_position = None + if sim_initial_position is not None: + initial_position = sim_initial_position * sign 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), + "initial_position": initial_position, + "velocity": sim_velocity, + "stppermm": sim_stppermm, + "referenced": sim_referenced, + "analog_inputs": sim_analog_inputs, + "digital_inputs": sim_digital_inputs, + "limit_low_active": sim_limit_low_active, + "limit_high_active": sim_limit_high_active, } class SimFlomniGalilMotor(FlomniGalilMotor): - """FlomniGalilMotor connected to the simulated Galil controller.""" + """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) + Note: the signature must list all deviceConfig keys explicitly. The BEC device + server only passes config keys that appear in inspect.signature(dev_cls) and only + injects device_manager if it is an explicit parameter. + """ + + def __init__( + self, + axis_Id, + prefix="", + *, + name, + host="mpc2844.psi.ch", + port=8081, + limits=None, + sign=1, + device_manager=None, + sim_initial_position=None, + sim_velocity=None, + sim_stppermm=None, + sim_referenced=True, + sim_analog_inputs=None, + sim_digital_inputs=None, + sim_limit_low_active=None, + sim_limit_high_active=None, + **kwargs, + ): + super().__init__( + axis_Id, + prefix, + name=name, + host=host, + port=port, + limits=limits, + sign=sign, + socket_cls=SimGalilSocket, + device_manager=device_manager, + **kwargs, + ) + state = SimStateRegistry.get(SimGalilState, host, port) + state.configure_axis( + self.axis_Id_numeric, + **_sim_state_kwargs( + sim_initial_position, + sim_velocity, + sim_stppermm, + sim_referenced, + sim_analog_inputs, + sim_digital_inputs, + sim_limit_low_active, + sim_limit_high_active, + self.sign, + ), + ) 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 + def __init__( + self, + axis_Id, + prefix="", + *, + name, + host="mpc2844.psi.ch", + port=8084, + limits=None, + sign=1, + device_manager=None, + sim_initial_position=None, + sim_velocity=None, + sim_referenced=True, + **kwargs, + ): + super().__init__( + axis_Id, + prefix, + name=name, + host=host, + port=port, + limits=limits, + sign=sign, + socket_cls=SimGalilSocket, + device_manager=device_manager, + **kwargs, + ) + state = SimStateRegistry.get(SimGalilState, host, port) 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) + if sim_velocity is None: + sim_velocity = 25.0 # deg/s, SPA=resol*25 after referencing + state.configure_axis( + self.axis_Id_numeric, + **_sim_state_kwargs( + sim_initial_position, + sim_velocity, + self.MOTOR_RESOLUTION, + sim_referenced, + None, + None, + None, + None, + self.sign, + ), + ) diff --git a/csaxs_bec/devices/sim/sim_rt_flomni.py b/csaxs_bec/devices/sim/sim_rt_flomni.py index 292bfba..873d88a 100644 --- a/csaxs_bec/devices/sim/sim_rt_flomni.py +++ b/csaxs_bec/devices/sim/sim_rt_flomni.py @@ -240,33 +240,75 @@ class SimRtFlomniSocket(SimSocketBase): ) -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.""" + """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 + The signature lists all deviceConfig keys explicitly; the BEC device server only + passes config keys present in inspect.signature(dev_cls). + """ + + def __init__( + self, + axis_Id, + prefix="", + *, + name, + host="mpc2844.psi.ch", + port=2222, + limits=None, + sign=1, + device_manager=None, + sim_point_dwell_s=None, + sim_pid_x_voltage=None, + sim_ssi_signal=None, + **kwargs, + ): + super().__init__( + axis_Id, + prefix, + name=name, + host=host, + port=port, + limits=limits, + sign=sign, + socket_cls=SimRtFlomniSocket, + device_manager=device_manager, + **kwargs, + ) + SimStateRegistry.get(SimRtFlomniState, host, port).configure( + point_dwell_s=sim_point_dwell_s, + pid_x_voltage=sim_pid_x_voltage, + ssi_signal=sim_ssi_signal, ) 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 + def __init__( + self, + prefix="", + *, + name, + host="mpc2844.psi.ch", + port=2222, + device_manager=None, + sim_point_dwell_s=None, + sim_pid_x_voltage=None, + sim_ssi_signal=None, + **kwargs, + ): + super().__init__( + prefix, + name=name, + host=host, + port=port, + socket_cls=SimRtFlomniSocket, + device_manager=device_manager, + **kwargs, + ) + SimStateRegistry.get(SimRtFlomniState, host, port).configure( + point_dwell_s=sim_point_dwell_s, + pid_x_voltage=sim_pid_x_voltage, + ssi_signal=sim_ssi_signal, ) diff --git a/csaxs_bec/devices/sim/sim_smaract.py b/csaxs_bec/devices/sim/sim_smaract.py index 192c4f8..2f13ed6 100644 --- a/csaxs_bec/devices/sim/sim_smaract.py +++ b/csaxs_bec/devices/sim/sim_smaract.py @@ -192,17 +192,47 @@ class SimSmaractSocket(SimSocketBase): class SimSmaractMotor(SmaractMotor): - """SmaractMotor connected to the simulated MCS controller.""" + """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) + The signature lists all deviceConfig keys explicitly; the BEC device server only + passes config keys present in inspect.signature(dev_cls). + """ + + def __init__( + self, + axis_Id, + prefix="", + *, + name, + host="mpc2844.psi.ch", + port=3334, + limits=None, + sign=1, + device_manager=None, + sim_initial_position=None, + sim_velocity=None, + sim_referenced=True, + **kwargs, + ): + super().__init__( + axis_Id, + prefix, + name=name, + host=host, + port=port, + limits=limits, + sign=sign, + socket_cls=SimSmaractSocket, + device_manager=device_manager, + **kwargs, + ) + state = SimStateRegistry.get(SimSmaractState, host, port) + initial_position = None + if sim_initial_position is not None: + initial_position = sim_initial_position * self.sign + state.configure_axis( + self.axis_Id_numeric, + initial_position=initial_position, + velocity=sim_velocity, + referenced=sim_referenced, + ) diff --git a/tests/sim_flomni_harness.py b/tests/sim_flomni_harness.py new file mode 100644 index 0000000..72bd599 --- /dev/null +++ b/tests/sim_flomni_harness.py @@ -0,0 +1,378 @@ +""" +Offline harness for the simulated flOMNI hardware stack. + +Instantiates the real device classes wired to the protocol simulations +(csaxs_bec.devices.sim) without a running BEC deployment and exercises the code +paths used by the flomni IPython-client scripts: + + 1. Galil coarse-stage moves and readback (fgalil protocol, both controllers) + 2. FUPR rotation incl. rotation-angle propagation to the RT controller + 3. drive_axis_to_limit / referencing (#FES) and limit-switch checks + 4. Smaract OSA moves + 5. RT feedback_enable_with_reset + laser tracker (cross-controller choreography) + 6. Fermat-like position scan through RtFlomniFlyer kickoff/complete readout + 7. Gripper sample transfer routine (#MNTMODE, #GRGET incl. confirm handshake) + 8. Simulated cameras (frame generation) + +Run with the repo root on PYTHONPATH: python tests/sim_flomni_harness.py +""" + +from __future__ import annotations + +import threading +import time + +import numpy as np + + +class FakeConfiguredDevice: + """Minimal stand-in for a bec_lib configured device (dm.devices.).""" + + def __init__(self, obj, user_parameter=None): + self.obj = obj + self.enabled = True + self.read_only = False + self.user_parameter = dict(user_parameter or {}) + + def update_user_parameter(self, update: dict): + self.user_parameter.update(update) + + +class FakeDeviceContainer(dict): + def __getattr__(self, name): + try: + return self[name] + except KeyError as exc: + raise AttributeError(name) from exc + + +class FakeDeviceManager: + def __init__(self): + self.devices = FakeDeviceContainer() + + def add(self, name, obj, user_parameter=None): + self.devices[name] = FakeConfiguredDevice(obj, user_parameter) + return obj + + +def check(condition, message): + status = "OK " if condition else "FAIL" + print(f"[{status}] {message}") + if not condition: + raise AssertionError(message) + + +def main(): # noqa: C901 + from csaxs_bec.devices.sim.sim_cameras import SimIDSCamera, SimWebcamViewer + from csaxs_bec.devices.sim.sim_galil import SimFlomniGalilMotor, SimFuprGalilMotor + from csaxs_bec.devices.sim.sim_rt_flomni import SimRtFlomniFlyer, SimRtFlomniMotor + from csaxs_bec.devices.sim.sim_smaract import SimSmaractMotor + + dm = FakeDeviceManager() + host = "mpc2844.psi.ch" + + # --- instantiate the simulated endstation ----------------------------------- + fsamx = dm.add( + "fsamx", + SimFlomniGalilMotor( + "E", + name="fsamx", + host=host, + port=8081, + sign=1, + limits=[-162, 0], + device_manager=dm, + sim_stppermm=2560, + sim_velocity=50, + sim_initial_position=-1.14, + ), + user_parameter={"in": -1.14}, + ) + fsamy = dm.add( + "fsamy", + SimFlomniGalilMotor( + "F", + name="fsamy", + host=host, + port=8081, + sign=1, + limits=[2, 3.8], + device_manager=dm, + sim_stppermm=1280000, + sim_velocity=0.1, + sim_initial_position=3, + ), + user_parameter={"in": 3}, + ) + ftransy = dm.add( + "ftransy", + SimFlomniGalilMotor( + "A", + name="ftransy", + host=host, + port=8081, + sign=1, + limits=[-100, 0], + device_manager=dm, + sim_stppermm=25600, + sim_velocity=5, + sim_initial_position=0, + sim_analog_inputs={1: -1.6}, + ), + user_parameter={"sensor_voltage": -1.6}, + ) + ftray = dm.add( + "ftray", + SimFlomniGalilMotor( + "D", + name="ftray", + host=host, + port=8081, + sign=-1, + limits=[-200, 0], + device_manager=dm, + sim_stppermm=25600, + sim_velocity=8, + sim_initial_position=0, + sim_limit_low_active=True, + ), + ) + foptx = dm.add( + "foptx", + SimFlomniGalilMotor( + "B", + name="foptx", + host=host, + port=8082, + sign=1, + limits=[-17, -12], + device_manager=dm, + sim_stppermm=6400, + sim_velocity=1, + sim_initial_position=-13.831, + ), + ) + fopty = dm.add( + "fopty", + SimFlomniGalilMotor( + "F", + name="fopty", + host=host, + port=8082, + sign=1, + limits=[0, 4], + device_manager=dm, + sim_stppermm=128000, + sim_velocity=0.05, + sim_initial_position=0.42, + ), + ) + foptz = dm.add( + "foptz", + SimFlomniGalilMotor( + "A", + name="foptz", + host=host, + port=8082, + sign=1, + limits=[0, 27], + device_manager=dm, + sim_stppermm=6400, + sim_velocity=1, + sim_initial_position=23, + ), + ) + ftrackz = dm.add( + "ftrackz", + SimFlomniGalilMotor( + "G", + name="ftrackz", + host=host, + port=8082, + sign=1, + limits=[4.5, 5.5], + device_manager=dm, + sim_stppermm=115200, + sim_velocity=0.02, + sim_initial_position=5, + ), + ) + fsamroy = dm.add( + "fsamroy", + SimFuprGalilMotor( + "A", + name="fsamroy", + host=host, + port=8084, + sign=-1, + limits=[-5, 365], + device_manager=dm, + sim_initial_position=0, + ), + ) + fosax = dm.add( + "fosax", + SimSmaractMotor( + "A", + name="fosax", + host=host, + port=3334, + sign=-1, + limits=[10.2, 10.6], + device_manager=dm, + sim_initial_position=10.4, + ), + ) + rtx = dm.add( + "rtx", + SimRtFlomniMotor( + "A", name="rtx", host=host, port=2222, sign=1, limits=[-200, 200], device_manager=dm + ), + user_parameter={"low_signal": 8500, "min_signal": 8000, "rt_pid_voltage": -0.06219}, + ) + rty = dm.add( + "rty", + SimRtFlomniMotor( + "B", name="rty", host=host, port=2222, sign=1, limits=[-100, 100], device_manager=dm + ), + user_parameter={"tomo_additional_offsety": 0}, + ) + rtz = dm.add( + "rtz", + SimRtFlomniMotor( + "C", name="rtz", host=host, port=2222, sign=1, limits=[-100, 100], device_manager=dm + ), + ) + flyer = dm.add( + "rt_positions", + SimRtFlomniFlyer( + name="rt_positions", host=host, port=2222, device_manager=dm, sim_point_dwell_s=0.005 + ), + ) + + for device in (fsamx, fsamy, ftransy, ftray, foptx, fopty, foptz, ftrackz, fsamroy, fosax): + device.wait_for_connection() + for device in (rtx, rty, rtz): + device.wait_for_connection() + flyer.controller.on() + print("--- all simulated devices connected ---") + + # --- 1. galil moves ---------------------------------------------------------- + check(np.isclose(fsamx.readback.get(), -1.14, atol=1e-3), "fsamx boots at -1.14 (in)") + t0 = time.time() + fsamx.move(-2.14, wait=True) + check(np.isclose(fsamx.readback.get(), -2.14, atol=1e-3), "fsamx moved to -2.14") + fsamx.move(-1.14, wait=True) + t0 = time.time() + fsamy.move(3.05, wait=True) + duration = time.time() - t0 + check(np.isclose(fsamy.readback.get(), 3.05, atol=1e-3), "fsamy moved to 3.05") + check(duration > 0.3, f"fsamy move at 0.1 mm/s took {duration:.2f} s (constant velocity)") + sensor = float(ftransy.controller.socket_put_and_receive("MG@AN[1]").strip()) + check(np.isclose(sensor, -1.6), "ftransy gripper sensor voltage reads -1.6") + + # --- 2. FUPR rotation + RT angle propagation --------------------------------- + fsamroy.move(90, wait=True) + check(np.isclose(fsamroy.readback.get(), 90, atol=0.1), "fsamroy rotated to 90 deg") + rt_state = rtx.controller.sock.state + check( + np.isclose(rt_state.angle_rad, 90 / 180 * np.pi, atol=1e-3), + "RT angle updated via FUPR readback", + ) + fsamroy.move(0, wait=True) + + # --- 3. referencing / limit switches ------------------------------------------ + check(ftray.controller.get_motor_limit_switch("D")[0], "ftray boots with 'IN' limit active") + foptz.controller.drive_axis_to_limit(0, "reverse") + check(np.isclose(foptz.readback.get(), 0.0, atol=1e-3), "foptz referenced to 0 at limit") + check(foptz.controller.get_motor_limit_switch("A")[0], "foptz reverse limit switch active") + curtain = foptz.controller.fosaz_light_curtain_is_triggered() + check(curtain, "fosaz light curtain reads triggered") + + # --- 4. smaract --------------------------------------------------------------- + fosax.move(10.5, wait=True) + check(np.isclose(fosax.readback.get(), 10.5, atol=1e-4), "fosax moved to 10.5") + check(fosax.controller.axis_is_referenced(0), "fosax referenced") + + # --- 5. RT feedback + tracker -------------------------------------------------- + rtx.controller.feedback_enable_with_reset() + check(rtx.controller.feedback_is_running(), "interferometer feedback running") + check(rtx.controller.laser_tracker_check_enabled(), "laser tracker enabled") + check(dm.devices["fsamx"].read_only, "fsamx set to read-only with feedback on") + signal_status = rtx.controller.laser_tracker_check_signalstrength(verbose=False) + check(signal_status == "ok", f"tracker signal strength '{signal_status}'") + + rtx.move(5.0, wait=True) + check(np.isclose(rtx.readback.get(), 5.0, atol=1e-3), "rtx moved to 5.0 with feedback") + rtx.controller.move_samx_to_scan_region(5.0) + + # --- 6. fermat-like scan through the flyer ------------------------------------- + num_points = 400 + positions = [[5.0 + 3 * np.sin(i * 0.3), 2 * np.cos(i * 0.3), 0.0] for i in range(num_points)] + rtx.controller.clear_trajectory_generator() + rtx.controller.add_pos_to_scan(positions) + flyer.stage() + status = flyer.kickoff() + status.wait(timeout=10) + complete_status = flyer.complete() + deadline = time.time() + 30 + while not complete_status.done and time.time() < deadline: + time.sleep(0.1) + check(complete_status.done, "flyer completed the scan readout") + data = flyer.data.get() + check(rtx.controller.get_scan_status()[0] == 0, "scan mode back to 0 after scan") + print(f" last data row: {data}") + + # --- 7. gripper transfer routine ----------------------------------------------- + con = ftransy.controller + con.socket_put_confirmed("XQ#MNTMODE") + time.sleep(0.1) + check(bool(float(con.socket_put_and_receive("MG mntmod").strip())), "controller in mount mode") + con.socket_put_confirmed("getaprch=-17.5") + con.socket_put_confirmed("XQ#GRGET,3") + + def confirmer(): + for _ in range(200): + confirm = int(float(con.socket_put_and_receive("MG confirm").strip())) + if confirm == -1: + con.socket_put_confirmed("confirm=1") + in_progress = bool(float(con.socket_put_and_receive("MG mntprgs").strip())) + if not in_progress: + return + time.sleep(0.1) + + confirm_thread = threading.Thread(target=confirmer) + confirm_thread.start() + deadline = time.time() + 30 + while time.time() < deadline: + if not bool(float(con.socket_put_and_receive("MG mntprgs").strip())): + break + time.sleep(0.2) + confirm_thread.join() + check( + not bool(float(con.socket_put_and_receive("MG mntprgs").strip())), + "gripper #GRGET routine finished (confirm handshakes served)", + ) + con.socket_put_confirmed("XQ#POSMODE") + time.sleep(0.1) + check( + not bool(float(con.socket_put_and_receive("MG mntmod").strip())), + "controller back in positioning mode", + ) + + # --- 8. cameras ----------------------------------------------------------------- + webcam = SimWebcamViewer(url="http://sim/video", name="cam_flomni_gripper") + ids_cam = SimIDSCamera(name="cam_xeye", camera_id=11, force_monochrome=True) + frame = ids_cam.cam.get_image_data() + check(frame.ndim == 2 and frame.max() > 200, f"IDS sim frame {frame.shape}, max {frame.max()}") + check(webcam._sim_frame.shape == (480, 640, 3), "webcam sim frame 480x640 RGB") + + # --- feedback disable puts stages back to writable ------------------------------- + rtx.controller.feedback_disable() + check(not dm.devices["fsamx"].read_only, "fsamx writable again after feedback_disable") + + print("\nAll harness checks passed.") + + +if __name__ == "__main__": + main() -- 2.54.0 From 080976b7b28803bc7b6c4bb68da8e3f90c0907b7 Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 11 Jul 2026 21:54:27 +0200 Subject: [PATCH 3/5] sim next version, including lamni and omny --- bin/generate_simulated_configs.py | 153 ++++ .../simulated_omny/simulated_flomni.yaml | 25 + .../simulated_omny/simulated_lamni.yaml | 349 ++++++++++ .../simulated_omny/simulated_omny.yaml | 654 ++++++++++++++++++ csaxs_bec/devices/sim/sim_cameras.py | 33 +- csaxs_bec/devices/sim/sim_galil.py | 162 ++++- csaxs_bec/devices/sim/sim_lamni.py | 405 +++++++++++ csaxs_bec/devices/sim/sim_omny.py | 281 ++++++++ csaxs_bec/devices/sim/sim_sample_storage.py | 49 ++ docs/SIMULATED_ENDSTATIONS.md | 153 ++++ tests/sim_flomni_harness.py | 61 +- tests/sim_lamni_omny_harness.py | 355 ++++++++++ 12 files changed, 2643 insertions(+), 37 deletions(-) create mode 100644 bin/generate_simulated_configs.py create mode 100644 csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml create mode 100644 csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml create mode 100644 csaxs_bec/devices/sim/sim_lamni.py create mode 100644 csaxs_bec/devices/sim/sim_omny.py create mode 100644 csaxs_bec/devices/sim/sim_sample_storage.py create mode 100644 docs/SIMULATED_ENDSTATIONS.md create mode 100644 tests/sim_lamni_omny_harness.py diff --git a/bin/generate_simulated_configs.py b/bin/generate_simulated_configs.py new file mode 100644 index 0000000..632930a --- /dev/null +++ b/bin/generate_simulated_configs.py @@ -0,0 +1,153 @@ +""" +Generate simulated device configs for LamNI and OMNY from the real ptycho configs. + +Mirrors ptycho_lamni.yaml / ptycho_omny.yaml, swapping deviceClass to the simulated +counterparts and injecting sim_* seed parameters (resolutions from lamni.dmc, boot +positions from the 'in' user parameters where available). Devices without a simulated +counterpart are dropped and reported. + +Run from the repo root: python bin/generate_simulated_configs.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import yaml + +REPO = Path(__file__).resolve().parents[1] +CONFIGS = REPO / "csaxs_bec" / "device_configs" + +CLASS_MAP = { + "csaxs_bec.devices.omny.galil.lgalil_ophyd.LamniGalilMotor": "csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor", + "csaxs_bec.devices.omny.rt.rt_lamni_ophyd.RtLamniMotor": "csaxs_bec.devices.sim.sim_lamni.SimRtLamniMotor", + "csaxs_bec.devices.omny.rt.rt_lamni_ophyd.RtLamniFlyer": "csaxs_bec.devices.sim.sim_lamni.SimRtLamniFlyer", + "csaxs_bec.devices.omny.galil.ogalil_ophyd.OMNYGalilMotor": "csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor", + "csaxs_bec.devices.omny.rt.rt_omny_ophyd.RtOMNYMotor": "csaxs_bec.devices.sim.sim_omny.SimRtOMNYMotor", + "csaxs_bec.devices.omny.rt.rt_omny_ophyd.RtOMNYFlyer": "csaxs_bec.devices.sim.sim_omny.SimRtOMNYFlyer", + "csaxs_bec.devices.omny.omny_sample_storage.OMNYSampleStorage": "csaxs_bec.devices.sim.sim_omny.SimOMNYSampleStorage", + "csaxs_bec.devices.omny.omny_dewar.OMNYDewar": "csaxs_bec.devices.sim.sim_omny.SimOMNYDewar", + "csaxs_bec.devices.omny.omny_temperatures.OMNYTemperatures": "csaxs_bec.devices.sim.sim_omny.SimOMNYTemperatures", + "csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor": "csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor", + "csaxs_bec.devices.ids_cameras.ids_camera.IDSCamera": "csaxs_bec.devices.sim.sim_cameras.SimIDSCamera", + "csaxs_bec.devices.omny.webcam_viewer.WebcamViewer": "csaxs_bec.devices.sim.sim_cameras.SimWebcamViewer", + "csaxs_bec.devices.omny.flomni_sample_storage.FlomniSampleStorage": "csaxs_bec.devices.sim.sim_sample_storage.SimFlomniSampleStorage", + "ophyd_devices.ComputedSignal": "ophyd_devices.ComputedSignal", +} + +# lamni.dmc #INIT: stppermm / encpermm per axis index (A=0 .. H=7) +LAMNI_STPPERMM = { + 0: 12800, + 1: 12800, + 2: 50154.32099, + 3: 12800, + 4: 12800, + 5: 38400, + 6: 12800, + 7: 12800, +} +LAMNI_ENCPERMM = {0: 2000, 1: 2000, 2: 36000, 3: 10000, 4: 10000, 5: 30000} +LAMNI_ENCODER_AXES = set(range(6)) +# lamni.dmc #INIT axspeed table (units/s) +LAMNI_VELOCITY = {0: 3, 1: 3, 2: 12, 3: 1, 4: 0.5, 5: 0.5, 6: 3, 7: 3} + +SIM_BEAMLINE_DEVICES = """ +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 +""" + + +def axis_index(axis_id: str) -> int: + return ord(axis_id.lower()) - ord("a") + + +def transform(src_name: str, dst_dir: str, dst_name: str, setup: str) -> None: + src = CONFIGS / src_name + config = yaml.safe_load(src.read_text()) + out, dropped = {}, [] + for name, dev in config.items(): + if not isinstance(dev, dict) or "deviceClass" not in dev: + dropped.append(name) + continue + dev_class = dev["deviceClass"].strip() + if dev_class not in CLASS_MAP: + dropped.append(f"{name} ({dev_class.rsplit('.', 1)[-1]})") + continue + dev = dict(dev) + dev["deviceClass"] = CLASS_MAP[dev_class] + dev_config = dict(dev.get("deviceConfig") or {}) + user_params = dev.get("userParameter") or {} + + if "SimLamniGalilMotor" in dev["deviceClass"]: + axis = axis_index(dev_config["axis_Id"]) + dev_config["sim_stppermm"] = LAMNI_STPPERMM.get(axis, 12800) + if axis in LAMNI_ENCODER_AXES: + dev_config["sim_encpermm"] = LAMNI_ENCPERMM[axis] + dev_config.setdefault("sim_velocity", LAMNI_VELOCITY.get(axis, 1)) + if "GalilMotor" in dev["deviceClass"] or "SmaractMotor" in dev["deviceClass"]: + # boot at the aligned position: 'center' (LamNI) or 'in' user parameter + initial = user_params.get("center", user_params.get("in", 0)) + limits = dev_config.get("limits") + if isinstance(limits, list) and len(limits) == 2 and None not in limits: + low, high = sorted(float(limit) for limit in limits) + # low == high means 'no limit check' in BEC; do not clamp then + if low < high and not low <= float(initial) <= high: + initial = round((low + high) / 2, 4) + dev_config["sim_initial_position"] = initial + if "SimRtOMNYFlyer" in dev["deviceClass"] or "SimRtLamniFlyer" in dev["deviceClass"]: + dev_config.setdefault("sim_point_dwell_s", 0.02) + + dev["deviceConfig"] = dev_config + out[name] = dev + + # LamNI: couple the rt interferometer readback to the coarse stages so that + # lamni_new_scan_center_interferometer converges (see SimRtLamniState) + if setup == "LamNI" and "lsamx" in out and "lsamy" in out: + coupling = { + "galil_port": out["lsamx"]["deviceConfig"]["port"], + "x_axis": axis_index(out["lsamx"]["deviceConfig"]["axis_Id"]), + "x_sign": out["lsamx"]["deviceConfig"].get("sign", 1), + "x_center": (config["lsamx"].get("userParameter") or {}).get("center", 0), + "y_axis": axis_index(out["lsamy"]["deviceConfig"]["axis_Id"]), + "y_sign": out["lsamy"]["deviceConfig"].get("sign", 1), + "y_center": (config["lsamy"].get("userParameter") or {}).get("center", 0), + } + for rt_name in ("rtx", "rty", "rt_positions"): + if rt_name in out: + out[rt_name]["deviceConfig"]["sim_coarse_coupling"] = coupling + + header = ( + f"############################################################\n" + f"# Simulated {setup} endstation (generated by bin/generate_simulated_configs.py)\n" + f"# Real device classes run against protocol-level simulations.\n" + f"# Dropped (no simulated counterpart): {', '.join(dropped) if dropped else 'none'}\n" + f"############################################################\n\n" + ) + body = yaml.dump(out, sort_keys=False, default_flow_style=False) + dst = CONFIGS / dst_dir / dst_name + dst.parent.mkdir(exist_ok=True) + (dst.parent / "__init__.py").touch() + dst.write_text(header + body + SIM_BEAMLINE_DEVICES) + print(f"{dst_name}: {len(out)} devices, dropped: {dropped}") + + +if __name__ == "__main__": + transform("ptycho_lamni.yaml", "simulated_omny", "simulated_lamni.yaml", "LamNI") + transform("ptycho_omny.yaml", "simulated_omny", "simulated_omny.yaml", "OMNY") diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml index 2890aed..aed27b4 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml @@ -500,6 +500,8 @@ cam_flomni_gripper: deviceClass: csaxs_bec.devices.sim.sim_cameras.SimWebcamViewer deviceConfig: url: http://flomnicamserver:5000/video_high + # per-frame noise in counts; 0 = static frames (default), e.g. 4 for visibly live previews + sim_noise_std: 0 num_rotation_90: 3 transpose: false enabled: true @@ -512,6 +514,8 @@ cam_flomni_overview: deviceClass: csaxs_bec.devices.sim.sim_cameras.SimWebcamViewer deviceConfig: url: http://flomnicamserver:5001/video_high + # per-frame noise in counts; 0 = static frames (default), e.g. 4 for visibly live previews + sim_noise_std: 0 num_rotation_90: 3 transpose: false enabled: true @@ -529,6 +533,8 @@ cam_xeye: transpose: true force_monochrome: true m_n_colormode: 1 + # per-frame noise in counts; 0 = static frames (default), e.g. 4 for visibly live previews + sim_noise_std: 0 enabled: true onFailure: buffer readOnly: false @@ -536,6 +542,25 @@ cam_xeye: userParameter: pixel_calibration: 0.00044247787610619477 # mm/pixel (= 0.05 / 113) +############################################################ +#################### Sample storage ######################## +############################################################ + +flomni_samples: + description: Sample storage database (simulated, in-memory) + deviceClass: csaxs_bec.devices.sim.sim_sample_storage.SimFlomniSampleStorage + deviceConfig: + # boots with a sample on the flomni stage (slot 0) and in slots 1-3 + sim_samples: + "0": "sim_stage_sample" + "1": "sim_sample_1" + "2": "sim_sample_2" + "3": "sim_sample_3" + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + ############################################################ #################### Computed signal ####################### ############################################################ diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml new file mode 100644 index 0000000..cb2ad85 --- /dev/null +++ b/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml @@ -0,0 +1,349 @@ +############################################################ +# Simulated LamNI endstation (generated by bin/generate_simulated_configs.py) +# Real device classes run against protocol-level simulations. +# Dropped (no simulated counterpart): none +############################################################ + +leyex: + description: Xray eye X + deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor + deviceConfig: + axis_Id: G + host: mpc2680.psi.ch + limits: + - 0 + - 0 + port: 8081 + sign: -1 + sim_stppermm: 12800 + sim_velocity: 3 + sim_initial_position: 14.117 + deviceTags: + - lamni + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: 14.117 +leyey: + description: Xray eye Y + deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor + deviceConfig: + axis_Id: H + host: mpc2680.psi.ch + limits: + - 0 + - 0 + port: 8081 + sign: -1 + sim_stppermm: 12800 + sim_velocity: 3 + sim_initial_position: 48.069 + deviceTags: + - lamni + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: 48.069 + out: 0.5 +loptx: + description: Optics X + deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor + deviceConfig: + axis_Id: E + host: mpc2680.psi.ch + limits: + - 0 + - 0 + port: 8081 + sign: 1 + sim_stppermm: 12800 + sim_encpermm: 10000 + sim_velocity: 0.5 + sim_initial_position: -0.244 + deviceTags: + - lamni + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: -0.244 + out: -0.699 +lopty: + description: Optics Y + deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor + deviceConfig: + axis_Id: F + host: mpc2680.psi.ch + limits: + - 0 + - 0 + port: 8081 + sign: 1 + sim_stppermm: 38400 + sim_encpermm: 30000 + sim_velocity: 0.5 + sim_initial_position: 3.724 + deviceTags: + - lamni + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: 3.724 + out: 3.53 +loptz: + description: Optics Z + deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor + deviceConfig: + axis_Id: D + host: mpc2680.psi.ch + limits: + - 0 + - 0 + port: 8081 + sign: -1 + sim_stppermm: 12800 + sim_encpermm: 10000 + sim_velocity: 1 + sim_initial_position: 0 + deviceTags: + - lamni + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 +lsamrot: + description: Sample rotation + deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor + deviceConfig: + axis_Id: C + host: mpc2680.psi.ch + limits: + - 0 + - 0 + port: 8081 + sign: 1 + sim_stppermm: 50154.32099 + sim_encpermm: 36000 + sim_velocity: 12 + sim_initial_position: 0 + deviceTags: + - lamni + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 +lsamx: + description: Sample coarse X + deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor + deviceConfig: + axis_Id: A + host: mpc2680.psi.ch + limits: + - 0 + - 0 + port: 8081 + sign: -1 + sim_stppermm: 12800 + sim_encpermm: 2000 + sim_velocity: 3 + sim_initial_position: 8.768 + deviceTags: + - lamni + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + center: 8.768 +lsamy: + description: Sample coarse Y + deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor + deviceConfig: + axis_Id: B + host: mpc2680.psi.ch + limits: + - 0 + - 0 + port: 8081 + sign: 1 + sim_stppermm: 12800 + sim_encpermm: 2000 + sim_velocity: 3 + sim_initial_position: 10.041 + deviceTags: + - lamni + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + center: 10.041 +losax: + description: OSA X + deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor + deviceConfig: + axis_Id: A + host: mpc2680.psi.ch + limits: + - 0 + - 0 + port: 8085 + sign: -1 + sim_initial_position: -1.442 + deviceTags: + - lamni + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: -1.442 +losay: + description: OSA Y + deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor + deviceConfig: + axis_Id: B + host: mpc2680.psi.ch + limits: + - 0 + - 0 + port: 8085 + sign: -1 + sim_initial_position: -0.171 + deviceTags: + - lamni + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: -0.171 + out: 3.8 +losaz: + description: OSA Z + deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor + deviceConfig: + axis_Id: C + host: mpc2680.psi.ch + limits: + - 0 + - 0 + port: 8085 + sign: 1 + sim_initial_position: -1 + deviceTags: + - lamni + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: -1 + out: -3 +rtx: + deviceClass: csaxs_bec.devices.sim.sim_lamni.SimRtLamniMotor + deviceConfig: + axis_Id: A + device_access: true + host: mpc2680.psi.ch + labels: rtx + limits: + - 0 + - 0 + port: 3333 + sign: 1 + sim_coarse_coupling: &id001 + galil_port: 8081 + x_axis: 0 + x_sign: -1 + x_center: 8.768 + y_axis: 1 + y_sign: 1 + y_center: 10.041 + deviceTags: + - lamni + readoutPriority: baseline + connectionTimeout: 20 + enabled: true + readOnly: false +rty: + deviceClass: csaxs_bec.devices.sim.sim_lamni.SimRtLamniMotor + deviceConfig: + axis_Id: B + device_access: true + host: mpc2680.psi.ch + labels: rty + limits: + - 0 + - 0 + port: 3333 + sign: 1 + sim_coarse_coupling: *id001 + deviceTags: + - lamni + readoutPriority: baseline + connectionTimeout: 20 + enabled: true + readOnly: false +rt_positions: + deviceClass: csaxs_bec.devices.sim.sim_lamni.SimRtLamniFlyer + deviceConfig: + host: mpc2680.psi.ch + port: 3333 + sim_point_dwell_s: 0.02 + sim_coarse_coupling: *id001 + readoutPriority: async + connectionTimeout: 20 + enabled: true + readOnly: false +cam_xeye: + description: Camera LamNI Xray eye ID15 + deviceClass: csaxs_bec.devices.sim.sim_cameras.SimIDSCamera + deviceConfig: + camera_id: 15 + bits_per_pixel: 24 + num_rotation_90: 3 + transpose: false + force_monochrome: true + m_n_colormode: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: async + +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 diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml new file mode 100644 index 0000000..d8bdb3c --- /dev/null +++ b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml @@ -0,0 +1,654 @@ +############################################################ +# Simulated OMNY endstation (generated by bin/generate_simulated_configs.py) +# Real device classes run against protocol-level simulations. +# Dropped (no simulated counterpart): none +############################################################ + +cam200: + description: Camera200 + deviceClass: csaxs_bec.devices.sim.sim_cameras.SimIDSCamera + deviceConfig: + camera_ID: 200 + bits_per_pixel: 24 + channels: 3 + m_n_colormode: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: async +cam201: + description: Camera201 + deviceClass: csaxs_bec.devices.sim.sim_cameras.SimIDSCamera + deviceConfig: + camera_ID: 201 + bits_per_pixel: 24 + channels: 3 + m_n_colormode: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: async +cam202: + description: Camera202 + deviceClass: csaxs_bec.devices.sim.sim_cameras.SimIDSCamera + deviceConfig: + camera_ID: 202 + bits_per_pixel: 24 + channels: 3 + m_n_colormode: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: async +cam203: + description: Camera203 + deviceClass: csaxs_bec.devices.sim.sim_cameras.SimIDSCamera + deviceConfig: + camera_ID: 203 + bits_per_pixel: 24 + channels: 3 + m_n_colormode: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: async +rtx: + description: OMNY rt + deviceClass: csaxs_bec.devices.sim.sim_omny.SimRtOMNYMotor + deviceConfig: + axis_Id: A + host: mpc3217.psi.ch + port: 3333 + sign: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: on_request + connectionTimeout: 20 + userParameter: + low_signal: 8500 + min_signal: 8000 +rty: + description: OMNY rt + deviceClass: csaxs_bec.devices.sim.sim_omny.SimRtOMNYMotor + deviceConfig: + axis_Id: B + host: mpc3217.psi.ch + port: 3333 + sign: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: on_request + connectionTimeout: 20 + userParameter: + tomo_additional_offsety: 0 +rtz: + description: OMNY rt + deviceClass: csaxs_bec.devices.sim.sim_omny.SimRtOMNYMotor + deviceConfig: + axis_Id: C + host: mpc3217.psi.ch + port: 3333 + sign: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: on_request + connectionTimeout: 20 +rt_positions: + deviceClass: csaxs_bec.devices.sim.sim_omny.SimRtOMNYFlyer + deviceConfig: + host: mpc3217.psi.ch + port: 3333 + sim_point_dwell_s: 0.02 + readoutPriority: async + connectionTimeout: 20 + enabled: true + readOnly: false +omny_samples: + description: OMNYSampleStorage + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYSampleStorage + deviceConfig: {} + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline +omny_dewar: + description: OMNY Dewar Information + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYDewar + deviceConfig: {} + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline +omny_temperatures: + description: OMNY Temperatures and pressures + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYTemperatures + deviceConfig: {} + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline +ofzpx: + description: FZP X + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: A + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8081 + sign: 1 + sim_initial_position: -0.4317 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: -0.4317 +ofzpy: + description: FZP Y + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: B + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8081 + sign: 1 + sim_initial_position: 0.7944 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: 0.7944 + out: 0.6377 +ofzpz: + description: FZP Z + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: C + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8081 + sign: -1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: 0 +otransx: + description: Transfer X + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: D + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8081 + sign: 1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: 0 +otransy: + description: Transfer Y + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: E + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8081 + sign: 1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + up_position: -1.2 + gripper_sensorvoltagetarget: -2.3 +otransz: + description: Transfer Z + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: F + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8081 + sign: 1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: 0 +osamx: + description: Sample X + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: A + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8082 + sign: 1 + sim_initial_position: -0.1 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: -0.1 +osamz: + description: Sample Z + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: B + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8082 + sign: 1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: false + onFailure: buffer + readOnly: true + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: 0 +oosay: + description: OSA Y + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: C + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8082 + sign: 1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + near_field_in: 0.531 + far_field_in: 0.4122 +oosax: + description: OSA X + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: D + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8082 + sign: -1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + near_field_in: 3.2044 + far_field_in: 3.022 +oosaz: + description: OSA Z + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: E + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8082 + sign: -1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + near_field_in: -0.4452 + far_field_in: 6.5 +oparkz: + description: OSA Y + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: F + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8082 + sign: 1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: 0 +oshuttleopen: + description: Shuttle opener + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: G + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8082 + sign: 1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: true + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: 0 +oshuttlealign: + description: Shuttle aligner + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: H + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8082 + sign: 1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: true + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: 0 +osamy: + description: Sample Y + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: A + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8083 + sign: 1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: 0 +otracky: + description: Laser Tracker Y + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: B + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8083 + sign: 1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + start_pos: -4.3431 +osamroy: + description: Sample rotation + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: C + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8083 + sign: 1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: 0 +otrackz: + description: Laser Tracker Z + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: E + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8083 + sign: -1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + start_pos: -0.6948 +oeyex: + description: Xray eye X + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: F + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8083 + sign: 1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + xray_in: -45.7394 +oeyez: + description: Xray eye Z + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: G + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8083 + sign: 1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + xray_in: -2 +oeyey: + description: Xray eye Y + deviceClass: csaxs_bec.devices.sim.sim_omny.SimOMNYGalilMotor + deviceConfig: + axis_Id: H + host: mpc3217.psi.ch + limits: + - 0 + - 0 + port: 8083 + sign: 1 + sim_initial_position: 0 + deviceTags: + - omny + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + xray_in: 0.0229 +ocsx: + description: Central Stop X + deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor + deviceConfig: + axis_Id: B + host: mpc3217.psi.ch + limits: + - -2 + - 2 + port: 3334 + sign: -1 + sim_initial_position: 0 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + nothing: 0 +ocsy: + description: Central Stop Y + deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor + deviceConfig: + axis_Id: A + host: mpc3217.psi.ch + limits: + - -2 + - 2 + port: 3334 + sign: 1 + sim_initial_position: 0 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + nothing: 0 +oshield: + description: Thermal Shield Sample Stage + deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor + deviceConfig: + axis_Id: C + host: mpc3217.psi.ch + limits: + - -14.5 + - 15.8 + port: 3334 + sign: 1 + sim_initial_position: 0 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + nothing: 0 + +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 diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py index 31cdfb4..f87c017 100644 --- a/csaxs_bec/devices/sim/sim_cameras.py +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -8,9 +8,10 @@ methods (`start_live_mode`, `set_rect_roi`, `get_last_image`, ...) and the ROI/t 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. +The synthetic frame is a test pattern (dark background, centered gaussian blob and a +crosshair). Per-frame gaussian noise can be enabled via sim_noise_std (counts, default 0 += static frames; noisy frames make live views visibly live but cost bandwidth e.g. over +remote desktop). """ from __future__ import annotations @@ -39,6 +40,14 @@ def make_test_pattern(height: int, width: int, rgb: bool) -> np.ndarray: return frame +def add_frame_noise(frame: np.ndarray, noise_std: float) -> np.ndarray: + """Return a copy of the frame with gaussian noise, clipped to uint8.""" + if noise_std <= 0: + return frame + noisy = frame.astype(np.float32) + np.random.normal(0.0, noise_std, frame.shape) + return noisy.clip(0, 255).astype(np.uint8) + + class SimWebcamViewer(WebcamViewer): """WebcamViewer serving a static synthetic frame instead of an MJPEG stream. @@ -53,16 +62,18 @@ class SimWebcamViewer(WebcamViewer): num_rotation_90=0, transpose=False, sim_shape=(480, 640), + sim_noise_std=0.0, **kwargs, ) -> None: self._sim_frame = make_test_pattern(int(sim_shape[0]), int(sim_shape[1]), rgb=True) + self._sim_noise_std = float(sim_noise_std) super().__init__( url=url, name=name, num_rotation_90=num_rotation_90, transpose=transpose, **kwargs ) def _update_loop(self) -> None: while not self._shutdown_event.is_set(): - self.preview.put(self._sim_frame) + self.preview.put(add_frame_noise(self._sim_frame, self._sim_noise_std)) self._shutdown_event.wait(0.2) # 5 Hz @@ -81,11 +92,12 @@ class _SimIDSSensor: class _SimIDSBackend: """Drop-in replacement for `base_integration.camera.Camera` serving synthetic frames.""" - def __init__(self, width: int, height: int, rgb: bool): + def __init__(self, width: int, height: int, rgb: bool, noise_std: float = 0.0): self.cam = _SimIDSSensor(width, height) self.force_monochrome = False self._connected = False self._rgb = rgb + self._noise_std = float(noise_std) self._frame = make_test_pattern(height, width, rgb=rgb) def on_connect(self): @@ -98,7 +110,7 @@ class _SimIDSBackend: frame = self._frame if self.force_monochrome and frame.ndim == 3: frame = frame[:, :, 0] - return frame + return add_frame_noise(frame, self._noise_std) def set_camera_rate_limiting(self, enabled: bool): pass @@ -120,9 +132,14 @@ class SimIDSCamera(IDSCamera): num_rotation_90: int = 0, transpose: bool = False, force_monochrome: bool = False, + camera_ID: int | None = None, + channels: int | None = None, # legacy OMNY config keys, accepted for compatibility sim_shape=(1024, 1280), + sim_noise_std=0.0, **kwargs, ): + if camera_ID is not None and not camera_id: + camera_id = camera_ID super().__init__( name=name, camera_id=camera_id, @@ -136,5 +153,7 @@ class SimIDSCamera(IDSCamera): force_monochrome=force_monochrome, **kwargs, ) - self.cam = _SimIDSBackend(int(sim_shape[1]), int(sim_shape[0]), rgb=not force_monochrome) + self.cam = _SimIDSBackend( + int(sim_shape[1]), int(sim_shape[0]), rgb=not force_monochrome, noise_std=sim_noise_std + ) self.cam.force_monochrome = self._force_monochrome diff --git a/csaxs_bec/devices/sim/sim_galil.py b/csaxs_bec/devices/sim/sim_galil.py index 1474287..247c5f8 100644 --- a/csaxs_bec/devices/sim/sim_galil.py +++ b/csaxs_bec/devices/sim/sim_galil.py @@ -45,6 +45,9 @@ class _SimGalilAxis: self.motor_on = True self.auto_motor_off = True # fgalil #MOTOFFA switches motors off when idle self.stppermm = 25600.0 + self.encpermm = None # encoder counts per unit; LamNI axes 0-5 read TP in encoder counts + self.pa_target_steps = None # pending absolute target (PA{ax}=) + self.pr_target_steps = None # pending relative target (PR{ax}=) self.speed_steps = 5.0 * 25600.0 self.pos_steps = 0.0 self._move_start_steps = 0.0 @@ -121,6 +124,17 @@ class SimGalilState: "allaxref": 0.0, "axisref": 0.0, "frmmv": 0.0, + # dialect status variables (LamNI / OMNY); overridable via sim_variables + "swver": 0.0, + "allaxrer": 0.0, + "caperr": 0.0, + "angintf": 0.0, + "tempab": 0.0, + "timeab": 0.0, + "IgNoFol": 0.0, + "grstat": 0.0, + "relpos": 0.0, + "toppin": -30.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 @@ -141,6 +155,8 @@ class SimGalilState: ax.connected = True if "stppermm" in kwargs and kwargs["stppermm"] is not None: ax.stppermm = float(kwargs["stppermm"]) + if "encpermm" in kwargs and kwargs["encpermm"] is not None: + ax.encpermm = float(kwargs["encpermm"]) 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: @@ -150,6 +166,12 @@ class SimGalilState: 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_outputs" in kwargs and kwargs["digital_outputs"]: + for chan, val in kwargs["digital_outputs"].items(): + self.digital_outputs[int(chan)] = float(val) + if "variables" in kwargs and kwargs["variables"]: + for var, val in kwargs["variables"].items(): + self.variables[str(var)] = float(val) if not isinstance(val, str) else 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) @@ -157,6 +179,7 @@ class SimGalilState: 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"]) + self.recompute_allaxref() def thread_active(self, thread_id: int) -> bool: if thread_id == 0: @@ -173,10 +196,20 @@ class SimGalilState: 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) + """Current value of the allaxref DMC variable. + + Like on the real controller, allaxref is a plain variable: it is recomputed by + the referencing routines (#FES/#FRM/#REFAX, see recompute_allaxref) but can also + be overwritten directly via 'allaxref=' until the next referencing. + """ + with self.lock: + return bool(self.variables.get("allaxref", 0.0)) + + def recompute_allaxref(self): + with self.lock: + connected = [ax for ax in self.axes if ax.connected] + referenced = bool(connected) and all(ax.referenced for ax in connected) + self.variables["allaxref"] = 1.0 if referenced else 0.0 # --- routines --------------------------------------------------------------- def stop_all(self): @@ -202,18 +235,20 @@ class SimGalilState: 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.""" + """Simulate #FES / #FRM: define zero at the reference. + + The outcome (position 0, referenced, limit switch) is applied synchronously so + that callers polling is_axis_moving and then immediately checking the limit + switch (drive_axis_to_limit) never race the result; only the apparent motion + (thread 0 active window) is time-based. + """ 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.motor_on = True + self.set_thread_active(0, 0.5) # emulate the drive duration + ax.pos_steps = 0.0 ax.referenced = True + self.recompute_allaxref() if direction > 0: ax.limit_high_active = True elif direction < 0: @@ -270,7 +305,7 @@ class SimGalilState: 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.variables["mntprgs"] = 1.0 if approach_var == "getaprch" else -1.0 self._transfer_abort.clear() self._transfer_thread = threading.Thread( target=self._transfer_routine, args=(approach_var,), daemon=True @@ -303,28 +338,73 @@ class SimGalilSocket(SimSocketBase): 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) + # direct axis commands: PA (absolute, counts), PR (relative, counts) + match = re.match(r"^P([AR])([A-H])=(-?[0-9.]+)$", line) if match: - axis = ord(match.group(1).lower()) - 97 - state.variables[f"_fupr_target_{axis}"] = float(match.group(2)) + axis = ord(match.group(2).lower()) - 97 + ax = state.axis(axis) + if match.group(1) == "A": + ax.pa_target_steps = float(match.group(3)) + else: + ax.pr_target_steps = float(match.group(3)) return ":" - # FUPR: begin motion, e.g. "BGA" + # begin motion, e.g. "BGA": pending absolute target wins, else relative 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) + if ax.pa_target_steps is not None: + ax.start_move(ax.pa_target_steps) + ax.pa_target_steps = None + elif ax.pr_target_steps is not None: + ax.start_move(ax.position() + ax.pr_target_steps) + ax.pr_target_steps = None + return ":" + + # motor servo on/off per axis (OMNY shuttle/dewar handling) + match = re.match(r"^SH([A-H])$", line) + if match: + state.axis(ord(match.group(1).lower()) - 97).motor_on = True + return ":" + match = re.match(r"^MO([A-H])?$", line) + if match: + if match.group(1): + state.axis(ord(match.group(1).lower()) - 97).motor_on = False + else: + for ax in state.axes: + ax.motor_on = False + return ":" + + # acceleration / deceleration / speed / limit-disable per axis + match = re.match(r"^(AC|DC|SP|LD)([A-H])=(-?[0-9.]+)$", line) + if match: + if match.group(1) == "SP": + state.axis(ord(match.group(2).lower()) - 97).speed_steps = float(match.group(3)) return ":" # FUPR: position readback in counts, e.g. "TPA" - match = re.match(r"^T[DP]([A-H])$", line) + 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}" + ax = state.axis(ord(match.group(2).lower()) - 97) + if match.group(1) == "P" and ax.encpermm is not None: + return f"{ax.position() / ax.stppermm * ax.encpermm:.4f}" + return f"{ax.position():.4f}" + + if line == "AB" or line.startswith("AB"): + # Abort: stops all motion and all program threads (variables keep their + # values, unlike XQ#STOP which also clears mntprgs/mntmod) + for ax in state.axes: + ax.stop_move() + state._transfer_abort.set() + state._thread_deadlines.clear() + state._tracker_running = False + return ":" + if line == "ST" or re.match(r"^ST[A-H]*$", line): + for ax in state.axes: + ax.stop_move() + return ":" match = _ASSIGNMENT_RE.match(line) if match: @@ -352,6 +432,10 @@ class SimGalilSocket(SimSocketBase): parsed = float(value) except ValueError: parsed = value + if name == "axisref" and index is not None and isinstance(parsed, float): + state.axis(int(index)).referenced = bool(parsed) + state.recompute_allaxref() + return ":" if index is not None: state.variables[f"{name}[{index}]"] = parsed else: @@ -395,7 +479,7 @@ class SimGalilSocket(SimSocketBase): 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": + if arg in ("allaxref", "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]) @@ -405,6 +489,10 @@ class SimGalilSocket(SimSocketBase): if arg.startswith("stppermm["): axis = int(arg.split("[")[1].split("]")[0]) return f"{state.axis(axis).stppermm:.4f}" + if arg.startswith("encpermm["): + axis = int(arg.split("[")[1].split("]")[0]) + ax = state.axis(axis) + return f"{ax.encpermm if ax.encpermm is not None else ax.stppermm:.4f}" with state.lock: if arg in state.variables: val = state.variables[arg] @@ -423,12 +511,10 @@ class SimGalilSocket(SimSocketBase): 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() + state.reference_axis(axis_index, direction) return ":" if routine == "#REFAX": # FUPR - threading.Thread(target=state.reference_axis, args=(0, 0.0), daemon=True).start() + state.reference_axis(0, 0.0) return ":" if routine == "#MOTON": # FUPR state.axis(0).motor_on = True @@ -461,6 +547,18 @@ class SimGalilSocket(SimSocketBase): state.set_thread_active(4, 1.0) state._set_gripper(False) return ":" + if routine == "#GRSTAT": + # gripper status measurement: report success + state.set_thread_active(4, 0.5) + with state.lock: + state.variables["grstat"] = 1.0 + return ":" + if routine == "#CRESET": + with state.lock: + state.variables["confirm"] = 0.0 + return ":" + if routine == "#SAFETY": + return ":" if routine == "#Tracker": with state.lock: if state.variables.get("tracken", 0.0) == 1.0 and state.all_axes_referenced(): @@ -480,6 +578,9 @@ def _sim_state_kwargs( sim_limit_low_active, sim_limit_high_active, sign, + sim_encpermm=None, + sim_digital_outputs=None, + sim_variables=None, ) -> dict: initial_position = None if sim_initial_position is not None: @@ -493,6 +594,9 @@ def _sim_state_kwargs( "digital_inputs": sim_digital_inputs, "limit_low_active": sim_limit_low_active, "limit_high_active": sim_limit_high_active, + "encpermm": sim_encpermm, + "digital_outputs": sim_digital_outputs, + "variables": sim_variables, } diff --git a/csaxs_bec/devices/sim/sim_lamni.py b/csaxs_bec/devices/sim/sim_lamni.py new file mode 100644 index 0000000..55c6be8 --- /dev/null +++ b/csaxs_bec/devices/sim/sim_lamni.py @@ -0,0 +1,405 @@ +""" +Simulated LamNI endstation devices. + +Galil (lamni.dmc, mpc2680:8081): reuses the shared SimGalilState with LamNI specifics — +encoder-read axes 0-5 (`TP`/`encpermm`), `swver=28`, error flags `allaxrer`/`caperr`, +air bearing lights/interlocks on outputs 1/9/13. Motion uses the same base +`naxis/ntarget/movereq/#NEWPAR` mechanics as flOMNI. + +RT (LAMNI_server.cpp, port 3333): its own protocol dialect — + J0/J1/J5/J6 feedback control (no reply), J2 -> "status,ssi0,ssi1", + J4 -> "status,pos_y,pos_x", J7 -> angle interferometer "status,pos,signal", + As (start cap sampling, no reply) / Ar -> 8-field cap sensor data, + Ss (start rt sampler, no reply) / Sr -> 7-field sampler sums, + V velocity limit (no reply), a (no reply) / ar -> angle, + pa/pr, scan s/sc/sd/sr/sh, r -> 21-field sample rows + (x/y target-avg-stdev, five cap-sensor avg/stdev pairs, angle avg/stdev), o/t. +""" + +from __future__ import annotations + +import random +import threading +import time + +from bec_lib.logger import bec_logger + +from csaxs_bec.devices.omny.galil.lgalil_ophyd import LamniGalilMotor +from csaxs_bec.devices.omny.rt.rt_lamni_ophyd import RtLamniFlyer, RtLamniMotor +from csaxs_bec.devices.sim.sim_galil import SimGalilSocket, SimGalilState, _sim_state_kwargs +from csaxs_bec.devices.sim.sim_socket import SimSocketBase, SimStateRegistry + +# single source of truth for the LamNI geometry (same constants the scan uses) +from csaxs_bec.scans.LamNIFermatScan import MOVEMENT_SCALE_X, MOVEMENT_SCALE_Y + +logger = bec_logger.logger + +_POSITION_NOISE_UM = 0.002 + + +class SimLamniGalilMotor(LamniGalilMotor): + """LamniGalilMotor connected to the simulated Galil controller. + + The signature lists all deviceConfig keys explicitly; the BEC device server only + passes config keys present in inspect.signature(dev_cls). + """ + + def __init__( + self, + axis_Id, + prefix="", + *, + name, + host="mpc2680.psi.ch", + port=8081, + limits=None, + sign=1, + device_manager=None, + sim_initial_position=None, + sim_velocity=None, + sim_stppermm=None, + sim_encpermm=None, + sim_referenced=True, + sim_analog_inputs=None, + sim_digital_inputs=None, + sim_digital_outputs=None, + sim_variables=None, + **kwargs, + ): + super().__init__( + axis_Id, + prefix, + name=name, + host=host, + port=port, + limits=limits, + sign=sign, + socket_cls=SimGalilSocket, + device_manager=device_manager, + **kwargs, + ) + state = SimStateRegistry.get(SimGalilState, host, port) + variables = {"swver": 28} + variables.update(sim_variables or {}) + # air off and rt-not-blocked by default so the rt feedback interlock passes + outputs = {"9": 1, "13": 1} + outputs.update(sim_digital_outputs or {}) + state.configure_axis( + self.axis_Id_numeric, + **_sim_state_kwargs( + sim_initial_position, + sim_velocity, + sim_stppermm, + sim_referenced, + sim_analog_inputs, + sim_digital_inputs, + None, + None, + self.sign, + sim_encpermm=sim_encpermm, + sim_digital_outputs=outputs, + sim_variables=variables, + ), + ) + + +class SimRtLamniState: + """Shared state of the simulated LAMNI communication server.""" + + 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.angle_rad = 0.0 + self.ssi = (12000.0, 12000.0) + self.angle_interferometer_signal = 12000.0 + self.cap_sensors = [1.0, 1.1, 8.36, 0.29, -4.08, 0.5, 0.6] + self.velocity_um_per_s = 0.0 + self.point_dwell_s = 0.02 + # coupling of the interferometer readback to the coarse stages (lsamx/lsamy): + # with feedback off, the measured position is determined by the coarse stages + self.coarse_coupling = None + 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("ssi_signal") is not None: + self.ssi = (float(kwargs["ssi_signal"]), float(kwargs["ssi_signal"])) + if kwargs.get("coarse_coupling") is not None: + self.coarse_coupling = dict(kwargs["coarse_coupling"]) + + def measured_positions(self): + """Interferometer-measured x/y in um. + + With the feedback loop running, the piezos hold the trajectory-generator + targets. With the loop off, the measured position follows the coarse stages + through the LamNI geometry: + (x, y) = lamni_from_stage_coordinates(lsamx - center_x, lsamy - center_y) * 1000. + This is what makes lamni_new_scan_center_interferometer converge. + """ + with self.lock: + if self.feedback_running or self.coarse_coupling is None: + return list(self.targets) + coupling = self.coarse_coupling + galil = SimStateRegistry.get( + SimGalilState, coupling.get("galil_host", self.host), coupling.get("galil_port", 8081) + ) + + def user_pos(axis_key, sign_key): + ax = galil.axis(int(coupling[axis_key])) + return ax.position() / ax.stppermm * float(coupling.get(sign_key, 1)) + + dx_stage = user_pos("x_axis", "x_sign") - float(coupling.get("x_center", 0.0)) + dy_stage = user_pos("y_axis", "y_sign") - float(coupling.get("y_center", 0.0)) + x_um = (dx_stage * 0.5 + dy_stage * MOVEMENT_SCALE_X) * 1000.0 + y_um = dy_stage * MOVEMENT_SCALE_Y * 1000.0 + return [x_um, y_um, self.targets[2]] + + 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[: len(self.scan_positions[index])] = self.scan_positions[index] + with self.lock: + self.scan_mode = 0 + + def sample_row(self, index: int) -> str: + with self.lock: + target = ( + self.scan_positions[index] + if 0 <= index < len(self.scan_positions) + else self.targets + ) + angle = self.angle_rad + caps = list(self.cap_sensors[2:7]) + noise = _POSITION_NOISE_UM + parts = [f"{index}", "100", f"{angle:.6f}"] + for target_val in (target[0], target[1]): + parts += [ + f"{target_val:.5f}", + f"{target_val + random.gauss(0.0, noise):.5f}", + f"{abs(random.gauss(noise, noise / 4)):.5f}", + ] + for cap in caps: + parts += [f"{cap + random.gauss(0.0, 0.001):.5f}", f"{0.001:.5f}"] + parts += [f"{angle + random.gauss(0.0, 1e-6):.5f}", f"{1e-6:.5f}"] + return ", ".join(parts) + "\n" + + +class SimRtLamniSocket(SimSocketBase): + """Simulated socket implementing the LAMNI communication server protocol.""" + + state_cls = SimRtLamniState + + def handle_command(self, line: str): # noqa: C901 + state: SimRtLamniState = self.state + cmd, args = line[0], line[1:] + + if cmd == "J": + if args.startswith("0") or args.startswith("6"): + state.feedback_running = False + return None + if args.startswith("1") or args.startswith("5"): + state.feedback_running = True + return None + if args.startswith("2"): + status = 0 if state.feedback_running else 1 + return f"{status},{state.ssi[0]:.0f},{state.ssi[1]:.0f}\n" + if args.startswith("3"): # start ssi averaging + return None + if args.startswith("4"): + measured = state.measured_positions() + return f"0,{measured[1]:f},{measured[0]:f}\n" + if args.startswith("7"): + return f"1,{state.angle_rad:f},{state.angle_interferometer_signal:f}\n" + return None + + if cmd == "A": + if args.startswith("s"): # start cap sensor sampling + return None + caps = state.cap_sensors + values = ",".join(f"{val + random.gauss(0.0, 0.001):.3f}" for val in caps) + return f"100,{values}\n" + + if cmd == "S": + if args.startswith("s"): # reset and start rt sampler + return None + return "100,0.0,0.0,0.0,0.0,0.0,0.0\n" + + if cmd == "V": # velocity limit + with state.lock: + state.velocity_um_per_s = float(args) if args else 0.0 + return None + + if cmd == "a": + if args.startswith("r"): + return f"{state.angle_rad:f}\n" + state.angle_rad = float(args) + return None + + if cmd == "p": + if args.startswith("r"): + measured = state.measured_positions() + return f"{measured[0]:f},{measured[1]:f},{measured[2]:f}\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[: len(values)] = values + return None + + if cmd == "s": + if args.startswith("c"): + state.clear_scan() + return None + if args.startswith("r"): + mode, num_pos, current = ( + state.scan_mode, + len(state.scan_positions), + state.scan_current, + ) + # LAMNI_server: "%.0f,%.0f,%.0f" - integer-formatted, parsed with int() + return f"{mode:.0f},{num_pos:.0f},{current:.0f}\n" + if args.startswith("d"): + num_pos = state.start_scan() + return f"Scan mode started with #positions in scan {num_pos:.0f}. Timing: Detector Trigger.\n" + if args.startswith("h"): + state.clear_scan() + return "0.0, 0.0, 0.0\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 == "o": + return "1\n" + if cmd == "t": + return "1\n" + if cmd == "d": + return None + + logger.warning(f"[sim rt lamni] {self.host}:{self.port} unhandled command '{line}'") + return None + + +def _rt_lamni_sim_config(sim_point_dwell_s, sim_ssi_signal, sim_coarse_coupling) -> dict: + return { + "point_dwell_s": sim_point_dwell_s, + "ssi_signal": sim_ssi_signal, + "coarse_coupling": sim_coarse_coupling, + } + + +class SimRtLamniMotor(RtLamniMotor): + """RtLamniMotor connected to the simulated LAMNI server.""" + + def __init__( + self, + axis_Id, + prefix="", + *, + name, + host="mpc2680.psi.ch", + port=3333, + limits=None, + sign=1, + device_manager=None, + device_access=None, + sim_point_dwell_s=None, + sim_ssi_signal=None, + sim_coarse_coupling=None, + **kwargs, + ): + # device_access is a BEC config flag (device_manager injection), not a + # constructor parameter of RtLamniMotor; accepted here only so the BEC + # kwarg filter passes it without error, then intentionally discarded + del device_access + super().__init__( + axis_Id, + prefix, + name=name, + host=host, + port=port, + limits=limits, + sign=sign, + socket_cls=SimRtLamniSocket, + device_manager=device_manager, + **kwargs, + ) + SimStateRegistry.get(SimRtLamniState, host, port).configure( + **_rt_lamni_sim_config(sim_point_dwell_s, sim_ssi_signal, sim_coarse_coupling) + ) + + +class SimRtLamniFlyer(RtLamniFlyer): + """RtLamniFlyer connected to the simulated LAMNI server.""" + + def __init__( + self, + prefix="", + *, + name, + host="mpc2680.psi.ch", + port=3333, + device_manager=None, + sim_point_dwell_s=None, + sim_ssi_signal=None, + sim_coarse_coupling=None, + **kwargs, + ): + super().__init__( + prefix, + name=name, + host=host, + port=port, + socket_cls=SimRtLamniSocket, + device_manager=device_manager, + **kwargs, + ) + SimStateRegistry.get(SimRtLamniState, host, port).configure( + **_rt_lamni_sim_config(sim_point_dwell_s, sim_ssi_signal, sim_coarse_coupling) + ) diff --git a/csaxs_bec/devices/sim/sim_omny.py b/csaxs_bec/devices/sim/sim_omny.py new file mode 100644 index 0000000..5aebf83 --- /dev/null +++ b/csaxs_bec/devices/sim/sim_omny.py @@ -0,0 +1,281 @@ +""" +Simulated OMNY endstation devices. + +Galil (controller1.dmc and siblings): reuses the shared SimGalilState — same base move +mechanics as flOMNI and the *identical* gripper transfer variable protocol +(getaprch/mntaprch/mntprgs/mntmod/confirm/atconfir, with mntax=4 and signed mntprgs), +plus OMNY status variables (swver=134, tempab/timeab, IgNoFol, grstat/relpos/toppin) +and direct axis commands for the shuttle/dewar handling (AC/DC/SP/PR/BG/SH/MO/LD). +Motor resolutions are hardcoded Python-side (51200; 89565.87 for the rotation axis on +port 8083) and seeded accordingly. + +RT (OMNY_server.cpp, port 3333): the flOMNI protocol's ancestor. SimRtOmnySocket is a +dialect of the flOMNI RT simulation: identical pa/pr/l/T/Ts/y/k/scan handling, plus +J0/J1/J3 (no reply) and J2 -> "status,ssi0..ssi4", and 19-field sample rows +(ST-OSA and OSA-FZP interferometer combinations, rotz, ST-FZP). + +EPICS periphery (sample storage incl. shuttle/dewar bookkeeping, dewar, temperatures) +uses the patched-device pattern with in-memory mock PVs. +""" + +from __future__ import annotations + +import random + +from bec_lib.logger import bec_logger +from ophyd_devices.tests.utils import patched_device + +from csaxs_bec.devices.omny.galil.ogalil_ophyd import OMNYGalilMotor +from csaxs_bec.devices.omny.omny_dewar import OMNYDewar +from csaxs_bec.devices.omny.omny_sample_storage import OMNYSampleStorage +from csaxs_bec.devices.omny.omny_temperatures import OMNYTemperatures +from csaxs_bec.devices.omny.rt.rt_omny_ophyd import RtOMNYFlyer, RtOMNYMotor +from csaxs_bec.devices.sim.sim_galil import SimGalilSocket, SimGalilState, _sim_state_kwargs +from csaxs_bec.devices.sim.sim_rt_flomni import SimRtFlomniSocket, SimRtFlomniState +from csaxs_bec.devices.sim.sim_socket import SimStateRegistry + +logger = bec_logger.logger + +_POSITION_NOISE_UM = 0.002 + +_OMNY_ROTATION_PORT = 8083 +_OMNY_ROTATION_AXIS = 2 +_OMNY_RESOLUTION = 51200.0 +_OMNY_ROTATION_RESOLUTION = 89565.8666667 + + +class SimOMNYGalilMotor(OMNYGalilMotor): + """OMNYGalilMotor connected to the simulated Galil controller. + + The signature lists all deviceConfig keys explicitly; the BEC device server only + passes config keys present in inspect.signature(dev_cls). + """ + + def __init__( + self, + axis_Id, + prefix="", + *, + name, + host="mpc2412.psi.ch", + port=8081, + limits=None, + sign=1, + device_manager=None, + sim_initial_position=None, + sim_velocity=None, + sim_referenced=True, + sim_analog_inputs=None, + sim_digital_inputs=None, + sim_digital_outputs=None, + sim_limit_low_active=None, + sim_limit_high_active=None, + sim_variables=None, + **kwargs, + ): + super().__init__( + axis_Id, + prefix, + name=name, + host=host, + port=port, + limits=limits, + sign=sign, + socket_cls=SimGalilSocket, + device_manager=device_manager, + **kwargs, + ) + # the OMNY controller additionally manages axis power via a NETIO switch + # webserver (http HTTP, outside the socket seam); simulate it as always-on + self.controller._ogalil_switchsocket_status = lambda: [1, 1, 1, 1] + self.controller._ogalil_switchsocket_switch_all_on = lambda: None + self.controller._ogalil_switchsocket_switch_all_off = lambda: None + state = SimStateRegistry.get(SimGalilState, host, port) + # resolutions are hardcoded in ogalil_ophyd.GalilMotorResolution + stppermm = _OMNY_RESOLUTION + if int(port) == _OMNY_ROTATION_PORT and self.axis_Id_numeric == _OMNY_ROTATION_AXIS: + stppermm = _OMNY_ROTATION_RESOLUTION + variables = {"swver": 134, "mntax": 4} + variables.update(sim_variables or {}) + state.configure_axis( + self.axis_Id_numeric, + **_sim_state_kwargs( + sim_initial_position, + sim_velocity, + stppermm, + sim_referenced, + sim_analog_inputs, + sim_digital_inputs, + sim_limit_low_active, + sim_limit_high_active, + self.sign, + sim_digital_outputs=sim_digital_outputs, + sim_variables=variables, + ), + ) + + +class SimRtOmnyState(SimRtFlomniState): + """OMNY RT server state: flOMNI state plus 5 interferometer signals and 19-field rows.""" + + def __init__(self, host=None, port=None): + super().__init__(host=host, port=port) + self.ssi_signals = [12000.0] * 5 + + def sample_row(self, index: int) -> str: + with self.lock: + target = ( + self.scan_positions[index] + if 0 <= index < len(self.scan_positions) + else self.targets + ) + angle = self.angle_rad + noise = _POSITION_NOISE_UM + fields = [f"{index}", "100", f"{angle:.6f}"] + # [3..8] target/avg/stdev of x and y (sample stage <-> OSA) + for target_val in (target[0], target[1]): + fields += [ + f"{target_val:.5f}", + f"{target_val + random.gauss(0.0, noise):.5f}", + f"{abs(random.gauss(noise, noise / 4)):.5f}", + ] + # [9..12] OSA <-> FZP x/y avg/stdev + fields += [f"{random.gauss(0.0, noise):.5f}", f"{noise:.5f}"] * 2 + # [13..14] rotz avg/stdev + fields += [f"{random.gauss(0.0, 0.1):.5f}", f"{0.02:.5f}"] + # [15..18] sample stage <-> FZP x/y avg/stdev + for target_val in (target[0], target[1]): + fields += [f"{target_val + random.gauss(0.0, noise):.5f}", f"{noise:.5f}"] + return ", ".join(fields) + "\n" + + +class SimRtOmnySocket(SimRtFlomniSocket): + """Simulated socket implementing the OMNY communication server protocol.""" + + state_cls = SimRtOmnyState + + def handle_command(self, line: str): + state: SimRtOmnyState = self.state + cmd, args = line[0], line[1:] + if cmd == "y": + # OMNY: single combined slew-rate-limiter flag (flOMNI returns the sum, 3) + return "1.000000\n" + if cmd == "J": + if args.startswith("2"): + signals = ",".join(f"{val:.0f}" for val in state.ssi_signals) + status = 0 if state.feedback_running else 1 + return f"{status},{signals}\n" + # J0 disable / J1 enable with reset / J3 averaging: no reply + if args.startswith("0"): + state.feedback_running = False + elif args.startswith("1"): + state.feedback_running = True + return None + return super().handle_command(line) + + +class SimRtOMNYMotor(RtOMNYMotor): + """RtOMNYMotor connected to the simulated OMNY server.""" + + def __init__( + self, + axis_Id, + prefix="", + *, + name, + host="mpc2412.psi.ch", + port=3333, + limits=None, + sign=1, + device_manager=None, + sim_point_dwell_s=None, + sim_pid_x_voltage=None, + sim_ssi_signal=None, + **kwargs, + ): + super().__init__( + axis_Id, + prefix, + name=name, + host=host, + port=port, + limits=limits, + sign=sign, + socket_cls=SimRtOmnySocket, + device_manager=device_manager, + **kwargs, + ) + SimStateRegistry.get(SimRtOmnyState, host, port).configure( + point_dwell_s=sim_point_dwell_s, + pid_x_voltage=sim_pid_x_voltage, + ssi_signal=sim_ssi_signal, + ) + + +class SimRtOMNYFlyer(RtOMNYFlyer): + """RtOMNYFlyer connected to the simulated OMNY server.""" + + def __init__( + self, + prefix="", + *, + name, + host="mpc2412.psi.ch", + port=3333, + device_manager=None, + sim_point_dwell_s=None, + sim_pid_x_voltage=None, + sim_ssi_signal=None, + **kwargs, + ): + super().__init__( + prefix, + name=name, + host=host, + port=port, + socket_cls=SimRtOmnySocket, + device_manager=device_manager, + **kwargs, + ) + SimStateRegistry.get(SimRtOmnyState, host, port).configure( + point_dwell_s=sim_point_dwell_s, + pid_x_voltage=sim_pid_x_voltage, + ssi_signal=sim_ssi_signal, + ) + + +_DEFAULT_OMNY_SAMPLES = {"1": "sim_sample_1", "2": "sim_sample_2", "3": "sim_sample_3"} + + +class SimOMNYSampleStorage(OMNYSampleStorage): + """OMNYSampleStorage with all EPICS PVs replaced by in-memory mock PVs.""" + + def __new__(cls, *args, sim_samples: dict | None = None, **kwargs): + with patched_device(OMNYSampleStorage, *args, **kwargs) as storage: + samples = _DEFAULT_OMNY_SAMPLES if sim_samples is None else sim_samples + storage.unset_sample_in_gripper() + storage.set_sample_in_samplestage("sim_stage_sample") + for slot_nr, name in samples.items(): + storage.set_sample_slot("A", int(slot_nr), str(name)) + return storage + + def __init__(self, *args, sim_samples: dict | None = None, **kwargs): + # instantiation handled in __new__ via patched_device; signature declares + # the deviceConfig keys for the BEC device server kwarg filter + pass + + +class SimOMNYDewar(OMNYDewar): + """OMNYDewar with all EPICS PVs replaced by in-memory mock PVs.""" + + def __new__(cls, *args, **kwargs): + with patched_device(OMNYDewar, *args, **kwargs) as dewar: + return dewar + + +class SimOMNYTemperatures(OMNYTemperatures): + """OMNYTemperatures with all EPICS PVs replaced by in-memory mock PVs.""" + + def __new__(cls, *args, **kwargs): + with patched_device(OMNYTemperatures, *args, **kwargs) as temperatures: + return temperatures diff --git a/csaxs_bec/devices/sim/sim_sample_storage.py b/csaxs_bec/devices/sim/sim_sample_storage.py new file mode 100644 index 0000000..602740e --- /dev/null +++ b/csaxs_bec/devices/sim/sim_sample_storage.py @@ -0,0 +1,49 @@ +""" +Simulated flOMNI sample storage. + +`FlomniSampleStorage` is purely EPICS-backed (XOMNY-SAMPLE_DB_flomni* PVs). The simulated +version follows the patched-device pattern of `simulated_beamline_devices`: the real class +is instantiated with all PVs replaced by in-memory mock PVs, so `set_sample_slot`, +`show_all`, the gripper bookkeeping and the sample-transfer scripts run unchanged; the +database content simply lives in process memory instead of EPICS. + +By default the storage boots with samples in slots 1-3 and a sample on the flOMNI stage +(slot 0), so `ftransfer_flomni_to_gripper` / `ftransfer_get_sample` can be exercised +immediately. Override via deviceConfig: + + sim_samples: + "0": "sim_stage_sample" + "1": "sim_sample_1" +""" + +from __future__ import annotations + +from ophyd_devices.tests.utils import patched_device + +from csaxs_bec.devices.omny.flomni_sample_storage import FlomniSampleStorage + +_DEFAULT_SAMPLES = { + "0": "sim_stage_sample", + "1": "sim_sample_1", + "2": "sim_sample_2", + "3": "sim_sample_3", +} + + +class SimFlomniSampleStorage(FlomniSampleStorage): + """FlomniSampleStorage with all EPICS PVs replaced by in-memory mock PVs.""" + + def __new__(cls, *args, sim_samples: dict | None = None, **kwargs): + with patched_device(FlomniSampleStorage, *args, **kwargs) as storage: + samples = _DEFAULT_SAMPLES if sim_samples is None else sim_samples + for slot_nr in range(21): + storage.unset_sample_slot(slot_nr) + storage.unset_sample_in_gripper() + for slot_nr, name in samples.items(): + storage.set_sample_slot(int(slot_nr), str(name)) + return storage + + def __init__(self, *args, sim_samples: dict | None = None, **kwargs): + # instantiation is fully handled in __new__ via patched_device; the signature + # only declares the deviceConfig keys for the BEC device server kwarg filter + pass diff --git a/docs/SIMULATED_ENDSTATIONS.md b/docs/SIMULATED_ENDSTATIONS.md new file mode 100644 index 0000000..954fb93 --- /dev/null +++ b/docs/SIMULATED_ENDSTATIONS.md @@ -0,0 +1,153 @@ +# Simulated Endstations for cSAXS BEC (flOMNI / LamNI / OMNY) + +Status: implemented and validated (2026-07). flOMNI extensively live-tested against a real +BEC deployment; LamNI/OMNY validated by offline harness, live smoke test pending. + +## Purpose + +Full software development and testing of the flOMNI/LamNI/OMNY control code without +hardware and without a running instrument. The real ophyd device classes, controllers, +scan plugins, GUI widgets, and IPython-client scripts run **unchanged**; only the wire +protocols are simulated. Swapping a device config is the only difference between the +simulated and the real system. + +## Architecture: protocol-level socket injection + +All three endstations funnel every hardware interaction through socket-based controllers +(`ophyd_devices.utils.controller.Controller`), which accept a `socket_cls` constructor +parameter. The simulation injects `SocketIO`-compatible classes that implement the wire +protocols as in-process state machines: + +- Commands are dispatched synchronously inside `put()`; replies go to a queue drained by + `receive()`. Commands that are reply-less on the real hardware must not enqueue replies + (the main desync risk of naive mocks) — reply behavior follows the real server sources. +- Simulation state is shared per (host, port) via `SimStateRegistry`, mirroring the + controller singleton. Device subclasses seed axis parameters at construction. +- The sim device classes are thin subclasses of the real ones whose only jobs are + (a) defaulting `socket_cls` and (b) seeding sim state from `sim_*` deviceConfig keys. +- Cross-device choreography (e.g. flOMNI `feedback_enable_with_reset` touching fsamroy, + fsamx and the ftrackz Galil) runs through the real Python code against each + controller's own sim socket — no logic is duplicated. + +Protocol fidelity is grounded in the actual hardware-side sources: `fgalil.dmc`, +`galil_micos_upr.dmc` (FUPR), `lamni.dmc`, `controller1.dmc` (OMNY), flOMNI +`CommunicationServer.cpp`, `LAMNI_server.cpp`, `OMNY_server.cpp`. + +## File inventory (all under csaxs_bec unless noted) + +- `devices/sim/sim_socket.py` — `SimSocketBase` + `SimStateRegistry` +- `devices/sim/sim_galil.py` — shared Galil DMC state machine (motion at constant + velocity, threads, referencing `#FES/#FRM/#REFAX`, gripper transfer routines + `#GRGET/#GRPUT` with `confirm` handshake and `atconfir` autoconfirm, `#Tracker`, + `#MNTMODE/#POSMODE`, `#STOP`, `AB`/`ST`, direct axis commands `PA/PR/BG/SH/MO/AC/DC/SP/LD`, + encoder axes `TP`/`encpermm`, dialect variables via `sim_variables`) plus + `SimFlomniGalilMotor`, `SimFuprGalilMotor` +- `devices/sim/sim_smaract.py` — Smaract MCS protocol + `SimSmaractMotor` (serves + flOMNI fosa*, LamNI losa*, OMNY ocs*/oshield) +- `devices/sim/sim_rt_flomni.py` — flOMNI Orchestra server (feedback l0/l1/l2/l3, + tracker T/Ts, scans s/sc/sd/sr, 10-field sample rows, paced by `sim_point_dwell_s`) +- `devices/sim/sim_lamni.py` — LamNI Galil dialect (swver 28, encoder axes 0–5, + air-bearing interlocks) + LAMNI server (J0/J1/J5/J6, J2/J4/J7, As/Ar cap sensors, + Ss/Sr sampler, V velocity, 21-field rows) +- `devices/sim/sim_omny.py` — OMNY Galil dialect (swver 134, mntax=4, signed mntprgs, + tempab/timeab/IgNoFol, grstat/relpos/toppin, `#GRSTAT/#CRESET/#SAFETY`, NETIO power + switch stubbed always-on) + OMNY server dialect (J2 six fields, `y`→1 semantics, + 19-field rows) + patched-EPICS `SimOMNYSampleStorage/Dewar/Temperatures` +- `devices/sim/sim_cameras.py` — `SimWebcamViewer` / `SimIDSCamera`: real camera classes + with synthetic frame source (crosshair + gaussian blob); optional per-frame noise via + `sim_noise_std` (default 0 = static, cheap over remote desktop) +- `devices/sim/sim_sample_storage.py` — `SimFlomniSampleStorage` (patched EPICS, + seedable via `sim_samples`, default slots 0–3 populated) +- `device_configs/simulated_omny/` — **all** simulated configs live here: + `simulated_flomni.yaml` (hand-maintained), `simulated_lamni.yaml` and + `simulated_omny.yaml` (generated), `simulated_bl_endstation.yaml` (pre-existing) +- `bin/generate_simulated_configs.py` — regenerates the LamNI/OMNY configs from + `ptycho_lamni.yaml`/`ptycho_omny.yaml` (rerun after real-config changes) +- `tests/sim_flomni_harness.py`, `tests/sim_lamni_omny_harness.py` — offline harnesses + (no BEC deployment needed): 39 + 26 checks covering moves, referencing, feedback, + tracker, scans through the flyers, gripper transfers, storage, cameras + +## Real-code fixes made along the way (separate commits) + +- `fgalil_ophyd.py` and `galil_ophyd.py` (base class, affects LamNI/OMNY): removed the + trailing thread-0 wait in the setpoint `_socket_set` — it blocked `user_setpoint.put()` (holding the + controller lock) for the whole move, so no intermediate readback/progress was possible. + Applies to the real instrument too. +- `ddg_1.py`: `bec_server.scan_server...ScanInfo` import moved under `TYPE_CHECKING` + (typing-only runtime dependency broke against the redeployed BEC layout). +- `flomni.py`: all four scilog sends wrapped in try/except RuntimeError — accounts + without scilog no longer crash `tomo_scan`/alignment/timing reports. + +## Delivery policy + +Tarballs contain ONLY simulation-package files (`devices/sim/*` except +`simulated_beamline_devices.py`, `device_configs/simulated_omny/*`, generator, harnesses, +this doc) plus the two agreed device-layer fixes (`galil_ophyd.py`, `fgalil_ophyd.py`, +`ddg_1.py`). Fixes to user-maintained files (`flomni.py`, ...) are delivered as diffs +only — never as full files, since the working copies evolve during testing. + +## BEC integration lessons (important for future sim devices) + +1. **Explicit signatures required**: the device server passes only deviceConfig keys that + appear as named parameters in `inspect.signature(dev_cls)`, and injects + `device_manager` only if named. `*args/**kwargs`-only subclasses silently lose their + entire config. +2. **msgpack strict_map_key**: no integer keys anywhere in deviceConfig — quote channel + numbers (`"1": -1.6`). +3. Hidden second transports exist outside the socket seam (OMNY NETIO power-switch HTTP, + camera MJPEG/pyueye) and need their own stubs. + +## Deliberate simulation semantics ("working hardware", no error injection) + +- Axes boot **referenced** (real init zeroes at limits; set `sim_referenced: false` to + exercise init procedures) at positions from `in` user params, clamped into limits. +- `allaxref`/`axisref[n]` are genuine writable DMC variables (forcing `allaxref=0` makes + moves refuse, as on hardware); recomputed by referencing routines. +- Constant-velocity motion with per-axis speeds from the DMC `#INIT` tables; RT scans + advance at `sim_point_dwell_s` per position (default 20 ms) so ETA/timing logic is + meaningfully testable. +- Sampled scan rows carry targets + ~2 nm gaussian noise. +- LamNI: the interferometer readback is coupled to the coarse stages — with feedback off, + measured (x, y) = lamni_from_stage_coordinates(lsamx-center, lsamy-center)*1000 um + (geometry constants imported from LamNIFermatScan). This makes + lamni_new_scan_center_interferometer converge. Coupling is configured via + `sim_coarse_coupling` on rtx/rty/rt_positions (generator fills it from the lsamx/lsamy + config). lsamx/lsamy boot at their 'center' user parameters; speeds follow the + lamni.dmc axspeed table. +- A LamNI fermat scan re-enables feedback itself (`feedback_enable_without_reset` in the + scan plugin) — starting a scan with user-disabled feedback therefore works, on the + real system as well as in the sim; it only fails on real hardware when feedback cannot + engage (air on, interferometer error), an error mode deliberately not simulated. +- Referencing (`#FES/#FRM/#REFAX`) applies its outcome synchronously (limit switch, + position 0) while only the apparent motion is time-based, so status polls never race + the result. +- Transfer confirm handshakes are real (GUI + gripper-camera flow works); set + `atconfir=1` on the controller for unattended runs. +- `#GRSTAT` always reports success; NETIO always on; unknown Galil commands reply `:` + and log `[sim galil] unhandled` warnings — grep the device-server log after a session + to find fidelity gaps worth adding (full DMC command-reference emulation was + deliberately not pursued). +- LamNI `sd` reply uses the detector-trigger wording; `sd` internal-timing + variant not modeled. `sr` replies are integer-formatted per LAMNI_server. + +## Live-test status (flOMNI, testing account, redeployed BEC) + +Passed: sample storage + GUI, moves incl. live progress bars and Ctrl-C, rotation→RT +angle coupling, feedback enable/disable/status, tracker, fermat scans incl. progress and +abort, X-ray eye GUI with camera frames, storage bookkeeping. Pending: full transfer +chain end-to-end, tomo_scan (unblocked by the scilog fix), tomo queue executor +(crash-resume checklist), then queue-reordering implementation. LamNI/OMNY: harness- +validated; a short live smoke test (config load, a move, one scan, one OMNY transfer) +recommended before relying on them, since flOMNI testing surfaced integration issues the +harness cannot see. + +## Operational notes + +- Deploy: extract the tarball in the repo root (overwrites sim files + the three fixed + real files). Remove stale `device_configs/simulated_flomni/` and `simulated_lamni/` + folders from earlier iterations if present. +- Sim tuning knobs (deviceConfig): `sim_initial_position`, `sim_velocity`, + `sim_stppermm`, `sim_encpermm`, `sim_referenced`, `sim_analog_inputs`, + `sim_digital_inputs/outputs`, `sim_variables`, `sim_limit_low/high_active`, + `sim_point_dwell_s`, `sim_noise_std`, `sim_samples`. +- Harnesses run standalone: `python tests/sim_flomni_harness.py` (repo on PYTHONPATH). diff --git a/tests/sim_flomni_harness.py b/tests/sim_flomni_harness.py index 72bd599..4c15c88 100644 --- a/tests/sim_flomni_harness.py +++ b/tests/sim_flomni_harness.py @@ -268,6 +268,17 @@ def main(): # noqa: C901 duration = time.time() - t0 check(np.isclose(fsamy.readback.get(), 3.05, atol=1e-3), "fsamy moved to 3.05") check(duration > 0.3, f"fsamy move at 0.1 mm/s took {duration:.2f} s (constant velocity)") + move_status = fsamy.move(3.0, wait=False) + time.sleep(0.25) + mid_position = fsamy.readback.get() + while not move_status.done: + time.sleep(0.05) + check( + 3.0 < mid_position < 3.05, f"intermediate readback during fsamy move ({mid_position:.4f})" + ) + check(np.isclose(fsamy.readback.get(), 3.0, atol=1e-3), "fsamy arrived at 3.0") + fsamy.move(3.05, wait=True) + sensor = float(ftransy.controller.socket_put_and_receive("MG@AN[1]").strip()) check(np.isclose(sensor, -1.6), "ftransy gripper sensor voltage reads -1.6") @@ -289,6 +300,29 @@ def main(): # noqa: C901 curtain = foptz.controller.fosaz_light_curtain_is_triggered() check(curtain, "fosaz light curtain reads triggered") + # --- 3b. allaxref is a real, writable DMC variable ------------------------------ + con1 = fsamx.controller + con1.socket_put_confirmed("allaxref=0") + check( + con1.socket_put_and_receive("MGallaxref").strip() == "0.0000", + "allaxref=0 sticks (MG allaxref reads 0)", + ) + move_refused = False + try: + fsamx.move(-1.2, wait=True) + except Exception: + move_refused = True + check(move_refused, "move refused while allaxref=0") + con1.socket_put_confirmed("allaxref=1") + check(con1.all_axes_referenced(), "allaxref=1 restores referenced state") + fsamx.move(-1.14, wait=True) + + con1.socket_put_and_receive("AB") + check( + not con1.is_thread_active(5) and not fsamx.controller.is_axis_moving("E", 4), + "AB aborts motion and program threads", + ) + # --- 4. smaract --------------------------------------------------------------- fosax.move(10.5, wait=True) check(np.isclose(fosax.readback.get(), 10.5, atol=1e-4), "fosax moved to 10.5") @@ -362,11 +396,36 @@ def main(): # noqa: C901 # --- 8. cameras ----------------------------------------------------------------- webcam = SimWebcamViewer(url="http://sim/video", name="cam_flomni_gripper") - ids_cam = SimIDSCamera(name="cam_xeye", camera_id=11, force_monochrome=True) + ids_cam = SimIDSCamera(name="cam_xeye", camera_id=11, force_monochrome=True, sim_noise_std=4.0) frame = ids_cam.cam.get_image_data() check(frame.ndim == 2 and frame.max() > 200, f"IDS sim frame {frame.shape}, max {frame.max()}") check(webcam._sim_frame.shape == (480, 640, 3), "webcam sim frame 480x640 RGB") + # --- 9. sample storage ----------------------------------------------------------- + from csaxs_bec.devices.sim.sim_sample_storage import SimFlomniSampleStorage + + samples = SimFlomniSampleStorage(name="flomni_samples") + check( + samples.is_sample_slot_used(0) and samples.is_sample_slot_used(3), + "sample storage boots with samples in slots 0-3", + ) + check(not samples.is_sample_in_gripper(), "gripper boots empty") + samples.set_sample_in_gripper(samples.get_sample_name(1)) + samples.unset_sample_slot(1) + check( + samples.is_sample_in_gripper() and not samples.is_sample_slot_used(1), + "slot 1 -> gripper bookkeeping", + ) + + frame_a = ids_cam.cam.get_image_data() + frame_b = ids_cam.cam.get_image_data() + check(not np.array_equal(frame_a, frame_b), "IDS frames are live (noise explicitly enabled)") + static_cam = SimIDSCamera(name="cam_xeye_static", camera_id=12, force_monochrome=True) + check( + np.array_equal(static_cam.cam.get_image_data(), static_cam.cam.get_image_data()), + "IDS frames are static by default (noise disabled)", + ) + # --- feedback disable puts stages back to writable ------------------------------- rtx.controller.feedback_disable() check(not dm.devices["fsamx"].read_only, "fsamx writable again after feedback_disable") diff --git a/tests/sim_lamni_omny_harness.py b/tests/sim_lamni_omny_harness.py new file mode 100644 index 0000000..870418e --- /dev/null +++ b/tests/sim_lamni_omny_harness.py @@ -0,0 +1,355 @@ +""" +Offline harness for the simulated LamNI and OMNY hardware stacks. + +Exercises the real device classes against the protocol simulations without a BEC +deployment, covering the paths used by the LamNI/OMNY IPython-client scripts. + +Run with the repo root on PYTHONPATH: python tests/sim_lamni_omny_harness.py +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).parent)) +from sim_flomni_harness import FakeDeviceManager, check # noqa: E402 + + +def run_lamni(): # noqa: C901 + from csaxs_bec.devices.sim.sim_lamni import SimLamniGalilMotor, SimRtLamniFlyer, SimRtLamniMotor + + dm = FakeDeviceManager() + host = "mpc2680.psi.ch" + lsamx = dm.add( + "lsamx", + SimLamniGalilMotor( + "A", + name="lsamx", + host=host, + port=8081, + sign=1, + device_manager=dm, + sim_stppermm=12800, + sim_encpermm=2000, + sim_initial_position=87.5, + ), + user_parameter={"center": 87.5}, + ) + lsamy = dm.add( + "lsamy", + SimLamniGalilMotor( + "B", + name="lsamy", + host=host, + port=8081, + sign=1, + device_manager=dm, + sim_stppermm=12800, + sim_encpermm=2000, + sim_initial_position=9.8, + ), + user_parameter={"center": 9.8}, + ) + lsamrot = dm.add( + "lsamrot", + SimLamniGalilMotor( + "C", + name="lsamrot", + host=host, + port=8081, + sign=1, + device_manager=dm, + sim_stppermm=50154.32099, + sim_encpermm=36000, + sim_velocity=20, + sim_initial_position=300, + ), + ) + coupling = { + "galil_port": 8081, + "x_axis": 0, + "x_sign": 1, + "x_center": 87.5, + "y_axis": 1, + "y_sign": 1, + "y_center": 9.8, + } + rtx = dm.add( + "rtx", + SimRtLamniMotor( + "A", + name="rtx", + host=host, + port=3333, + sign=1, + device_manager=dm, + limits=[-200, 200], + sim_coarse_coupling=coupling, + ), + ) + rty = dm.add( + "rty", + SimRtLamniMotor( + "B", name="rty", host=host, port=3333, sign=1, device_manager=dm, limits=[-200, 200] + ), + ) + flyer = dm.add( + "rt_positions", + SimRtLamniFlyer( + name="rt_positions", host=host, port=3333, device_manager=dm, sim_point_dwell_s=0.005 + ), + ) + for device in (lsamx, lsamy, lsamrot, rtx, rty): + device.wait_for_connection() + flyer.controller.on() + print("--- LamNI simulated devices connected ---") + + # encoder-read axes: TP scaled by encpermm + check(np.isclose(lsamx.readback.get(), 87.5, atol=1e-3), "lamni: lsamx boots at center 87.5") + lsamx.move(88.0, wait=True) + check( + np.isclose(lsamx.readback.get(), 88.0, atol=1e-3), + "lamni: lsamx encoder readback after move", + ) + raw_tp = float(lsamx.controller.socket_put_and_receive("TPA")) + check( + np.isclose(raw_tp, 88.0 * 2000, atol=5), f"lamni: TP returns encoder counts ({raw_tp:.0f})" + ) + lsamx.move(87.5, wait=True) + + # rotation propagates to rt with the 300 - 30.538 deg offset + lsamrot.move(310, wait=True) + rt_state = rtx.controller.sock.state + expected = (310 - 300 + 30.538) / 180 * np.pi + check( + np.isclose(rt_state.angle_rad, expected, atol=1e-3), + "lamni: rt angle follows lsamrot with offset", + ) + lsamrot.move(300, wait=True) + + move_status = lsamrot.move(320, wait=False) + time.sleep(0.4) + mid = lsamrot.readback.get() + while not move_status.done: + time.sleep(0.05) + check(300 < mid < 320, f"lamni: intermediate readback during lsamrot move ({mid:.2f})") + lsamrot.move(300, wait=True) + + check( + lsamx.controller.lgalil_is_air_off_and_orchestra_enabled(), + "lamni: air off / orchestra enabled interlock", + ) + check( + float(lsamx.controller.socket_put_and_receive("MGswver")) == 28.0, + "lamni: galil reports swver 28", + ) + + rtx.controller.feedback_enable_with_reset() + check(rtx.controller.feedback_is_running(), "lamni: feedback running after enable with reset") + rtx.move(5.0, wait=True) + check(np.isclose(rtx.readback.get(), 5.0, atol=1e-3), "lamni: rtx J4 readback after move") + + positions = [[5 + np.sin(i * 0.3), np.cos(i * 0.3)] for i in range(200)] + rtx.controller.clear_trajectory_generator() + rtx.controller.add_pos_to_scan(positions) + row = rtx.controller.socket_put_and_receive("r0").split(",") + check(len(row) == 21, f"lamni: sample row has 21 fields ({len(row)})") + signals = flyer._get_signals_from_table(row) + check( + np.isclose(float(signals["target_x"]["value"]), positions[0][0], atol=0.01), + "lamni: flyer parses target_x from row", + ) + mode, planned, current = rtx.controller.get_scan_status() + check((mode, planned) == (0, 200), "lamni: get_scan_status parses integer sr reply") + flyer.stage() + status = flyer.kickoff() + status.wait(timeout=10) + complete_status = flyer.complete() + deadline = time.time() + 30 + while not complete_status.done and time.time() < deadline: + time.sleep(0.1) + check(complete_status.done, "lamni: flyer completed the scan readout") + check(rtx.controller.get_scan_status()[0] == 0, "lamni: scan mode back to 0 after scan") + rtx.controller.feedback_disable() + + # coarse-stage coupling: with feedback off, the interferometer follows lsamx/lsamy + # through the LamNI geometry (this is what makes the scan-center routine converge) + from csaxs_bec.scans.LamNIFermatScan import lamni_from_stage_coordinates + + lsamx.move(87.55, wait=True) # +0.05 mm from center + expected_x, expected_y = (val * 1000 for val in lamni_from_stage_coordinates(0.05, 0.0)) + check( + np.isclose(rtx.readback.get(), expected_x, atol=0.5) + and np.isclose(rty.readback.get(), expected_y, atol=0.5), + f"lamni: interferometer follows coarse stage ({rtx.readback.get():.1f} um)", + ) + lsamx.move(87.5, wait=True) + check( + np.isclose(rtx.readback.get(), 0.0, atol=0.5), "lamni: interferometer back to 0 at center" + ) + print("--- LamNI checks passed ---\n") + + +def run_omny(): # noqa: C901 + from csaxs_bec.devices.sim.sim_omny import ( + SimOMNYDewar, + SimOMNYGalilMotor, + SimOMNYSampleStorage, + SimOMNYTemperatures, + SimRtOMNYFlyer, + SimRtOMNYMotor, + ) + + dm = FakeDeviceManager() + host = "mpc2412.psi.ch" + otransy = dm.add( + "otransy", + SimOMNYGalilMotor( + "E", + name="otransy", + host=host, + port=8081, + sign=1, + device_manager=dm, + sim_initial_position=0, + ), + ) + otracky = dm.add( + "otracky", + SimOMNYGalilMotor( + "G", + name="otracky", + host=host, + port=8082, + sign=1, + device_manager=dm, + sim_initial_position=2.5, + ), + ) + rtx = dm.add( + "rtx", + SimRtOMNYMotor( + "A", name="rtx", host=host, port=3333, sign=1, device_manager=dm, limits=[-200, 200] + ), + user_parameter={"low_signal": 8500, "min_signal": 8000}, + ) + rty = dm.add( + "rty", + SimRtOMNYMotor( + "B", name="rty", host=host, port=3333, sign=1, device_manager=dm, limits=[-100, 100] + ), + ) + rtz = dm.add( + "rtz", + SimRtOMNYMotor( + "C", name="rtz", host=host, port=3333, sign=1, device_manager=dm, limits=[-100, 100] + ), + ) + flyer = dm.add( + "rt_positions", + SimRtOMNYFlyer( + name="rt_positions", host=host, port=3333, device_manager=dm, sim_point_dwell_s=0.005 + ), + ) + for device in (otransy, otracky, rtx, rty, rtz): + device.wait_for_connection() + flyer.controller.on() + print("--- OMNY simulated devices connected ---") + + con = otransy.controller + check(float(con.socket_put_and_receive("MGswver")) == 134.0, "omny: galil reports swver 134") + check( + float(con.socket_put_and_receive("MGtempab")) == 0.0 + and float(con.socket_put_and_receive("MGtimeab")) == 0.0, + "omny: temperature/time abort flags clear", + ) + check( + float(con.socket_put_and_receive("MGtoppin")) < -25.4, + "omny: toppin position below threshold", + ) + + # motor resolution hardcoded python-side must match seeded stppermm + check(np.isclose(otransy.motor_resolution.get(), 51200), "omny: motor resolution 51200") + otransy.move(5.0, wait=True) + check(np.isclose(otransy.readback.get(), 5.0, atol=1e-3), "omny: otransy moved to 5.0") + + # shuttle-style direct axis commands + con.socket_put_confirmed("SPG=15000") + con.socket_put_confirmed("SHG") + con.socket_put_confirmed("PRG=17000") + con.socket_put_confirmed("BGG") + time.sleep(0.1) + while bool(float(con.socket_put_and_receive("MG_BGG").strip())): + time.sleep(0.05) + shuttle_pos = float(con.socket_put_and_receive("TDG")) + check( + np.isclose(shuttle_pos, 17000, atol=10), + f"omny: shuttle PR/BG relative move ({shuttle_pos:.0f})", + ) + con.socket_put_confirmed("MOG") + + # gripper transfer with mntax=4 (otransy axis E): identical protocol to flomni + con.socket_put_confirmed("XQ#MNTMODE") + con.socket_put_confirmed("atconfir=1") # autoconfirm for unattended harness run + con.socket_put_confirmed("mntaprch=-17.5") + con.socket_put_confirmed("XQ#GRPUT,3") + time.sleep(0.2) + check( + int(float(con.socket_put_and_receive("MGmntprgs").strip())) == -1, + "omny: unmount in progress reports mntprgs -1", + ) + deadline = time.time() + 30 + while time.time() < deadline: + if not float(con.socket_put_and_receive("MGmntprgs").strip()): + break + time.sleep(0.2) + check( + float(con.socket_put_and_receive("MGmntprgs").strip()) == 0, + "omny: #GRPUT finished with autoconfirm", + ) + con.socket_put_confirmed("XQ#GRSTAT") + time.sleep(0.1) + check( + bool(float(con.socket_put_and_receive("MGgrstat").strip())), "omny: #GRSTAT reports success" + ) + con.socket_put_confirmed("XQ#POSMODE") + + # RT: J2 signal strengths, feedback, scan rows + j2 = rtx.controller.socket_put_and_receive("J2").strip().split(",") + check(len(j2) == 6, f"omny: J2 returns 6 fields ({len(j2)})") + rtx.controller.socket_put("l1") + check(rtx.controller.feedback_is_running(), "omny: feedback running after l1") + rtx.move(3.0, wait=True) + check(np.isclose(rtx.readback.get(), 3.0, atol=1e-3), "omny: rtx pr readback after move") + + positions = [[3 + np.sin(i * 0.3), np.cos(i * 0.3), 0.0] for i in range(200)] + rtx.controller.clear_trajectory_generator() + rtx.controller.add_pos_to_scan(positions) + row = rtx.controller.socket_put_and_receive("r0").split(",") + check(len(row) == 19, f"omny: sample row has 19 fields ({len(row)})") + signals = flyer._get_signals_from_table(row) + check( + np.isclose(float(signals["target_x"]["value"]), positions[0][0], atol=0.01), + "omny: flyer parses target_x from row", + ) + + # EPICS periphery + storage = SimOMNYSampleStorage(name="omny_samples") + check( + storage.is_sample_slot_used("A", 1) and storage.is_sample_in_samplestage(), + "omny: sample storage boots seeded (slots + samplestage)", + ) + SimOMNYDewar(name="omny_dewar") + SimOMNYTemperatures(name="omny_temperatures") + check(True, "omny: dewar and temperatures instantiate with mock PVs") + print("--- OMNY checks passed ---\n") + + +if __name__ == "__main__": + run_lamni() + run_omny() + print("All LamNI/OMNY harness checks passed.") -- 2.54.0 From 7babac3f9a25b13c23a67fa5ce52de5d2f39540c Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 11 Jul 2026 21:58:17 +0200 Subject: [PATCH 4/5] remove sleep in socket set, required for progress bar in sim endstations --- csaxs_bec/devices/omny/galil/fgalil_ophyd.py | 2 -- csaxs_bec/devices/omny/galil/galil_ophyd.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/csaxs_bec/devices/omny/galil/fgalil_ophyd.py b/csaxs_bec/devices/omny/galil/fgalil_ophyd.py index 0444295..b03cd63 100644 --- a/csaxs_bec/devices/omny/galil/fgalil_ophyd.py +++ b/csaxs_bec/devices/omny/galil/fgalil_ophyd.py @@ -123,8 +123,6 @@ class FlomniGalilSetpointSignal(GalilSetpointSignal): self.controller.socket_put_confirmed(f"ntarget={target_val:.3f}") self.controller.socket_put_confirmed("movereq=1") self.controller.socket_put_confirmed("XQ#NEWPAR") - while self.controller.is_thread_active(0): - time.sleep(0.005) else: raise GalilError("Not all axes are referenced.") diff --git a/csaxs_bec/devices/omny/galil/galil_ophyd.py b/csaxs_bec/devices/omny/galil/galil_ophyd.py index 217bd6f..063e2b0 100644 --- a/csaxs_bec/devices/omny/galil/galil_ophyd.py +++ b/csaxs_bec/devices/omny/galil/galil_ophyd.py @@ -427,8 +427,6 @@ class GalilSetpointSignal(GalilSignalBase): self.controller.socket_put_confirmed(f"ntarget={target_val:.3f}") self.controller.socket_put_confirmed("movereq=1") self.controller.socket_put_confirmed("XQ#NEWPAR") - while self.controller.is_thread_active(0): - time.sleep(0.005) else: raise GalilError("Not all axes are referenced.") -- 2.54.0 From 70c27e1340f6ddee920d1aa243b4db4798d52e07 Mon Sep 17 00:00:00 2001 From: x01dc Date: Sun, 12 Jul 2026 07:47:01 +0200 Subject: [PATCH 5/5] fix/galil back to original code --- csaxs_bec/devices/omny/galil/fgalil_ophyd.py | 2 ++ csaxs_bec/devices/omny/galil/galil_ophyd.py | 2 ++ csaxs_bec/devices/sim/sim_galil.py | 9 ++++++--- docs/SIMULATED_ENDSTATIONS.md | 13 +++++++------ 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/csaxs_bec/devices/omny/galil/fgalil_ophyd.py b/csaxs_bec/devices/omny/galil/fgalil_ophyd.py index b03cd63..0444295 100644 --- a/csaxs_bec/devices/omny/galil/fgalil_ophyd.py +++ b/csaxs_bec/devices/omny/galil/fgalil_ophyd.py @@ -123,6 +123,8 @@ class FlomniGalilSetpointSignal(GalilSetpointSignal): self.controller.socket_put_confirmed(f"ntarget={target_val:.3f}") self.controller.socket_put_confirmed("movereq=1") self.controller.socket_put_confirmed("XQ#NEWPAR") + while self.controller.is_thread_active(0): + time.sleep(0.005) else: raise GalilError("Not all axes are referenced.") diff --git a/csaxs_bec/devices/omny/galil/galil_ophyd.py b/csaxs_bec/devices/omny/galil/galil_ophyd.py index 063e2b0..217bd6f 100644 --- a/csaxs_bec/devices/omny/galil/galil_ophyd.py +++ b/csaxs_bec/devices/omny/galil/galil_ophyd.py @@ -427,6 +427,8 @@ class GalilSetpointSignal(GalilSignalBase): self.controller.socket_put_confirmed(f"ntarget={target_val:.3f}") self.controller.socket_put_confirmed("movereq=1") self.controller.socket_put_confirmed("XQ#NEWPAR") + while self.controller.is_thread_active(0): + time.sleep(0.005) else: raise GalilError("Not all axes are referenced.") diff --git a/csaxs_bec/devices/sim/sim_galil.py b/csaxs_bec/devices/sim/sim_galil.py index 247c5f8..f80e2a6 100644 --- a/csaxs_bec/devices/sim/sim_galil.py +++ b/csaxs_bec/devices/sim/sim_galil.py @@ -182,9 +182,11 @@ class SimGalilState: self.recompute_allaxref() def thread_active(self, thread_id: int) -> bool: - if thread_id == 0: - if any(ax.is_moving() for ax in self.axes): - return True + # thread 0 only dispatches moves (#NEWPAR) and runs referencing (#FES); on the + # real controller it is active for milliseconds while the motion itself is + # carried by the firmware (MG_BG) - modeled via short deadlines, NOT by axis + # motion. This keeps the trailing thread-0 wait in the real setpoint code + # returning quickly, as on real hardware. if thread_id == 3: if self._transfer_thread is not None and self._transfer_thread.is_alive(): return True @@ -222,6 +224,7 @@ class SimGalilState: self._tracker_running = False def start_move_from_newpar(self): + self.set_thread_active(0, 0.05) # dispatch window with self.lock: if self.variables.get("movereq", 0.0) != 1.0: return diff --git a/docs/SIMULATED_ENDSTATIONS.md b/docs/SIMULATED_ENDSTATIONS.md index 954fb93..412a90f 100644 --- a/docs/SIMULATED_ENDSTATIONS.md +++ b/docs/SIMULATED_ENDSTATIONS.md @@ -69,10 +69,6 @@ Protocol fidelity is grounded in the actual hardware-side sources: `fgalil.dmc`, ## Real-code fixes made along the way (separate commits) -- `fgalil_ophyd.py` and `galil_ophyd.py` (base class, affects LamNI/OMNY): removed the - trailing thread-0 wait in the setpoint `_socket_set` — it blocked `user_setpoint.put()` (holding the - controller lock) for the whole move, so no intermediate readback/progress was possible. - Applies to the real instrument too. - `ddg_1.py`: `bec_server.scan_server...ScanInfo` import moved under `TYPE_CHECKING` (typing-only runtime dependency broke against the redeployed BEC layout). - `flomni.py`: all four scilog sends wrapped in try/except RuntimeError — accounts @@ -82,8 +78,7 @@ Protocol fidelity is grounded in the actual hardware-side sources: `fgalil.dmc`, Tarballs contain ONLY simulation-package files (`devices/sim/*` except `simulated_beamline_devices.py`, `device_configs/simulated_omny/*`, generator, harnesses, -this doc) plus the two agreed device-layer fixes (`galil_ophyd.py`, `fgalil_ophyd.py`, -`ddg_1.py`). Fixes to user-maintained files (`flomni.py`, ...) are delivered as diffs +this doc) plus the single agreed device-layer fix (`ddg_1.py`). Fixes to user-maintained files (`flomni.py`, ...) are delivered as diffs only — never as full files, since the working copies evolve during testing. ## BEC integration lessons (important for future sim devices) @@ -118,6 +113,12 @@ only — never as full files, since the working copies evolve during testing. scan plugin) — starting a scan with user-disabled feedback therefore works, on the real system as well as in the sim; it only fails on real hardware when feedback cannot engage (air on, interferometer error), an error mode deliberately not simulated. +- Thread 0 is dispatch-only, as on the real controller: `#NEWPAR` keeps it active for + ~50 ms while the motion itself is reported via the firmware flags (`MG_BG`, motor on). + The trailing thread-0 wait in the (unmodified) setpoint code therefore returns quickly + and move progress is published, matching real hardware. An earlier sim iteration + wrongly modeled thread 0 as busy for the whole move and "fixed" the device code to + match — that change was reverted; the device files are untouched. - Referencing (`#FES/#FRM/#REFAX`) applies its outcome synchronously (limit switch, position 0) while only the apparent motion is time-based, so status polls never race the result. -- 2.54.0