Feat/flomni omny lamni sim #257

Merged
holler merged 7 commits from feat/flomni_sim into main 2026-07-12 07:52:37 +02:00
16 changed files with 4971 additions and 1 deletions
+153
View File
@@ -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")
@@ -0,0 +1,599 @@
############################################################
# 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
# 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
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
# 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
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
# 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
readoutPriority: async
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 #######################
############################################################
calculated_signal:
description: Calculated signal from alignment for fit
deviceClass: ophyd_devices.ComputedSignal
deviceConfig:
compute_method: "def just_rand():\n return 42"
enabled: true
readOnly: false
readoutPriority: baseline
############################################################
######### Simulated beamline devices (shutter, DDG) ########
############################################################
ddg1:
description: Simulated main delay generator for triggering
deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.SimulatedDDG1
enabled: true
deviceConfig:
prefix: 'X12SA-CPCL-DDG1:'
onFailure: raise
readOnly: false
readoutPriority: baseline
softwareTrigger: true
fsh:
description: Simulated fast shutter manual control and readback
deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.cSAXSSimulatedFastShutter
deviceConfig:
prefix: 'X12SA-ES1-TTL:'
onFailure: raise
enabled: true
readoutPriority: monitored
@@ -0,0 +1,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
@@ -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
@@ -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
+159
View File
@@ -0,0 +1,159 @@
"""
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 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
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
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.
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),
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(add_frame_noise(self._sim_frame, self._sim_noise_std))
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, 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):
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 add_frame_noise(frame, self._noise_std)
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,
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,
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,
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, noise_std=sim_noise_std
)
self.cam.force_monochrome = self._force_monochrome
+712
View File
@@ -0,0 +1,712 @@
"""
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.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
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,
# 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
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 "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:
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_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)
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"])
self.recompute_allaxref()
def thread_active(self, thread_id: int) -> bool:
# 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
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:
"""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=<val>' 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):
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):
self.set_thread_active(0, 0.05) # dispatch window
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: 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.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:
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 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
)
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 ":"
# 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(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 ":"
# 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
ax = state.axis(axis)
ax.motor_on = True
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)
if match:
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:
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 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:
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 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])
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}"
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]
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))
state.reference_axis(axis_index, direction)
return ":"
if routine == "#REFAX": # FUPR
state.reference_axis(0, 0.0)
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 == "#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():
state._tracker_running = True
return ":"
logger.warning(f"[sim galil] {self.host}:{self.port} unhandled XQ '{arg}'")
return ":"
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,
sim_encpermm=None,
sim_digital_outputs=None,
sim_variables=None,
) -> dict:
initial_position = None
if sim_initial_position is not None:
initial_position = sim_initial_position * sign
return {
"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,
"encpermm": sim_encpermm,
"digital_outputs": sim_digital_outputs,
"variables": sim_variables,
}
class SimFlomniGalilMotor(FlomniGalilMotor):
"""FlomniGalilMotor connected to the simulated Galil controller.
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,
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_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,
),
)
+405
View File
@@ -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<um_per_s> velocity limit (no reply), a<rad> (no reply) / ar -> angle,
pa/pr, scan s/sc/sd/sr/sh, r<i> -> 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)
)
+281
View File
@@ -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
+314
View File
@@ -0,0 +1,314 @@
"""
Simulated flOMNI RT (Orchestra) communication server.
Implements the TCP command set of `flOMNI/FeedbackLoop/tcpip_server/CommunicationServer.cpp`
used by `RtFlomniController`, `RtFlomniMotor` and `RtFlomniFlyer`. Reply formats and, in
particular, which commands are reply-less (`pa`, `sc`, `l0/l1/l3`, `a<val>`, `T0/T1`,
`v0/v1`, emitter setters) follow the real server to keep the command/reply stream in sync.
Simulated behavior:
- interferometer feedback and laser tracker as simple boolean states; tracker reports
on-target beam positions and healthy intensity when enabled
- position scans (`s...`, `sd`, `sr`, `r<i>`) advance with a configurable per-point dwell
time so that scan durations, progress updates and the flyer readout behave realistically
- sampled data rows contain the target positions plus small gaussian noise
"""
from __future__ import annotations
import random
import threading
import time
from bec_lib.logger import bec_logger
from csaxs_bec.devices.omny.rt.rt_flomni_ophyd import RtFlomniFlyer, RtFlomniMotor
from csaxs_bec.devices.sim.sim_socket import SimSocketBase, SimStateRegistry
logger = bec_logger.logger
_POSITION_NOISE_UM = 0.002 # ~2 nm rms positioning noise in the sampled data
class SimRtFlomniState:
"""Shared state of the simulated Orchestra server (one per host:port)."""
def __init__(self, host=None, port=None):
self.host = host
self.port = port
self.lock = threading.RLock()
self.targets = [0.0, 0.0, 0.0]
self.feedback_running = False
self.tracker_enabled = False
self.angle_rad = 0.0
self.pid_x_voltage = -0.062
self.emitter = {"ty": 0.0, "tz": 0.0, "thr_laser": 1000.0, "psd_low": 100.0}
self.ssi_signal = 12000.0
self.tracker_intensity = 30000.0
self.point_dwell_s = 0.02
self.scan_positions = []
self.scan_mode = 0
self.scan_current = 0
self._scan_thread = None
self._scan_abort = threading.Event()
def configure(self, **kwargs):
with self.lock:
if kwargs.get("point_dwell_s") is not None:
self.point_dwell_s = float(kwargs["point_dwell_s"])
if kwargs.get("pid_x_voltage") is not None:
self.pid_x_voltage = float(kwargs["pid_x_voltage"])
if kwargs.get("ssi_signal") is not None:
self.ssi_signal = float(kwargs["ssi_signal"])
# --- scan handling -----------------------------------------------------------
def clear_scan(self):
self._scan_abort.set()
if self._scan_thread is not None and self._scan_thread.is_alive():
self._scan_thread.join(timeout=2)
with self.lock:
self.scan_positions = []
self.scan_mode = 0
self.scan_current = 0
self.targets = [0.0, 0.0, self.targets[2]]
def add_scan_position(self, pos):
with self.lock:
self.scan_positions.append(pos)
return len(self.scan_positions)
def start_scan(self) -> int:
with self.lock:
num_pos = len(self.scan_positions)
if num_pos == 0 or self.scan_mode > 0:
return num_pos
self.scan_mode = 3
self.scan_current = 0
self._scan_abort.clear()
self._scan_thread = threading.Thread(target=self._scan_loop, daemon=True)
self._scan_thread.start()
return num_pos
def _scan_loop(self):
with self.lock:
self.scan_mode = 2
num_pos = len(self.scan_positions)
for index in range(num_pos):
if self._scan_abort.wait(self.point_dwell_s):
return
with self.lock:
self.scan_current = index + 1
self.targets = list(self.scan_positions[index])
with self.lock:
self.scan_mode = 0
def scan_status(self):
with self.lock:
return (self.scan_mode, len(self.scan_positions), self.scan_current)
def sample_row(self, index: int) -> str:
with self.lock:
if 0 <= index < len(self.scan_positions):
target = self.scan_positions[index]
else:
target = self.targets
noise = _POSITION_NOISE_UM
avg_x = target[0] + random.gauss(0.0, noise)
avg_y = target[1] + random.gauss(0.0, noise)
rotz = random.gauss(0.0, 0.1)
return (
f"{index}, 100, {target[0]:.5f}, {avg_x:.5f}, {abs(random.gauss(noise, noise / 4)):.5f},"
f" {target[1]:.5f}, {avg_y:.5f}, {abs(random.gauss(noise, noise / 4)):.5f},"
f" {rotz:.5f}, {abs(random.gauss(0.1, 0.02)):.5f}\n"
)
class SimRtFlomniSocket(SimSocketBase):
"""Simulated socket implementing the flOMNI Orchestra communication server protocol."""
state_cls = SimRtFlomniState
def handle_command(self, line: str): # noqa: C901
state: SimRtFlomniState = self.state
cmd, args = line[0], line[1:]
if cmd == "l":
if args.startswith("0"):
state.feedback_running = False
return None
if args.startswith("1") or args.startswith("3"):
state.feedback_running = True
return None
if args.startswith("2"):
return f"{0 if state.feedback_running else 1}\n"
return None
if cmd == "p":
if args.startswith("r"):
targets = state.targets
return f"{targets[0]:.5f},{targets[1]:.5f},{targets[2]:.5f}\n"
if args.startswith("a"):
axis_str, value_str = args[1:].split(",")
with state.lock:
state.targets[int(float(axis_str))] = float(value_str)
return None
values = [float(val) for val in args.split(",")]
with state.lock:
state.targets = values[:3]
return None
if cmd == "s":
if args.startswith("c"):
state.clear_scan()
return None
if args.startswith("r"):
mode, num_pos, current = state.scan_status()
return f"{mode:.5f},{num_pos:.5f},{current:.5f}\n"
if args.startswith("d"):
num_pos = state.start_scan()
return f"Scan started {num_pos:.0f} positions.\n"
if args.startswith("h"):
state.clear_scan()
return "0.00000, 0.00000, 0.00000\n"
count = state.add_scan_position([float(val) for val in args.split(",")])
return f"{count:.0f}\n"
if cmd == "r":
return state.sample_row(int(args))
if cmd == "a":
if args.startswith("r"):
return f"{state.angle_rad:f}\n"
state.angle_rad = float(args)
return None
if cmd == "T":
if args.startswith("s"):
return self._tracker_status()
state.tracker_enabled = args.startswith("1")
return None
if cmd == "e":
if args[:1] in ("y", "z", "t", "l"):
key = {"y": "ty", "z": "tz", "t": "thr_laser", "l": "psd_low"}[args[0]]
with state.lock:
state.emitter[key] = float(args[1:])
return None
emitter = state.emitter
return (
f"{emitter['ty']:.5f},{emitter['tz']:.5f},"
f"{emitter['thr_laser']:.2f},{emitter['psd_low']:.5f}\n"
)
if cmd == "g":
return f"{state.pid_x_voltage + random.gauss(0.0, 0.005):f}\n"
if cmd == "G":
return f"{random.gauss(0.0, 0.005):f}\n"
if cmd == "y":
return "3.000000\n"
if cmd == "w":
return "32.000000\n"
if cmd == "j":
return f"{state.ssi_signal:f}\n"
if cmd == "k":
axis = int(args)
return f"{state.targets[axis] if axis < 3 else 0.0:f}\n"
if cmd == "v":
return None
if cmd == "d":
return None
if cmd == "o":
# not present in the current real server; provided for API completeness
return "1\n"
if cmd == "t":
return "1"
logger.warning(f"[sim rt] {self.host}:{self.port} unhandled command '{line}'")
return None
def _tracker_status(self) -> str:
state: SimRtFlomniState = self.state
enabled = 1.0 if state.tracker_enabled else 0.0
intensity = state.tracker_intensity
threshold = state.emitter["thr_laser"]
target_z = state.emitter["tz"]
target_y = state.emitter["ty"]
return (
f"{target_z:.2f},{target_z:.2f},{intensity:.2f},{threshold:.2f},{5.0:.2f},"
f"{target_y:.2f},{target_y:.2f},{intensity:.2f},{threshold:.2f},{5.0:.2f},"
f"{enabled:.0f}\n"
)
class SimRtFlomniMotor(RtFlomniMotor):
"""RtFlomniMotor connected to the simulated Orchestra server.
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,
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,
)
@@ -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
+238
View File
@@ -0,0 +1,238 @@
"""
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.
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,
)
+110
View File
@@ -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
+154
View File
@@ -0,0 +1,154 @@
# 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 05,
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 03 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)
- `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 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)
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.
- 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.
- 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<cycles>` 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).
+437
View File
@@ -0,0 +1,437 @@
"""
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.<name>)."""
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)")
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")
# --- 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")
# --- 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")
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, 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")
print("\nAll harness checks passed.")
if __name__ == "__main__":
main()
+355
View File
@@ -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.")