sim next version, including lamni and omny

This commit is contained in:
x01dc
2026-07-12 07:52:37 +02:00
committed by holler
co-authored by holler
parent bcedf794a5
commit 6533226924
12 changed files with 2643 additions and 37 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")
@@ -500,6 +500,8 @@ cam_flomni_gripper:
deviceClass: csaxs_bec.devices.sim.sim_cameras.SimWebcamViewer
deviceConfig:
url: http://flomnicamserver:5000/video_high
# per-frame noise in counts; 0 = static frames (default), e.g. 4 for visibly live previews
sim_noise_std: 0
num_rotation_90: 3
transpose: false
enabled: true
@@ -512,6 +514,8 @@ cam_flomni_overview:
deviceClass: csaxs_bec.devices.sim.sim_cameras.SimWebcamViewer
deviceConfig:
url: http://flomnicamserver:5001/video_high
# per-frame noise in counts; 0 = static frames (default), e.g. 4 for visibly live previews
sim_noise_std: 0
num_rotation_90: 3
transpose: false
enabled: true
@@ -529,6 +533,8 @@ cam_xeye:
transpose: true
force_monochrome: true
m_n_colormode: 1
# per-frame noise in counts; 0 = static frames (default), e.g. 4 for visibly live previews
sim_noise_std: 0
enabled: true
onFailure: buffer
readOnly: false
@@ -536,6 +542,25 @@ cam_xeye:
userParameter:
pixel_calibration: 0.00044247787610619477 # mm/pixel (= 0.05 / 113)
############################################################
#################### Sample storage ########################
############################################################
flomni_samples:
description: Sample storage database (simulated, in-memory)
deviceClass: csaxs_bec.devices.sim.sim_sample_storage.SimFlomniSampleStorage
deviceConfig:
# boots with a sample on the flomni stage (slot 0) and in slots 1-3
sim_samples:
"0": "sim_stage_sample"
"1": "sim_sample_1"
"2": "sim_sample_2"
"3": "sim_sample_3"
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
############################################################
#################### Computed signal #######################
############################################################
@@ -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
+26 -7
View File
@@ -8,9 +8,10 @@ methods (`start_live_mode`, `set_rect_roi`, `get_last_image`, ...) and the ROI/t
logic therefore run through the unmodified code paths, so GUIs and the X-ray eye
alignment can be exercised against the simulation.
The synthetic frame is a static test pattern: dark background, centered gaussian blob
and a crosshair, which gives the previews something recognizable and provides contrast
for image analysis.
The synthetic frame is a test pattern (dark background, centered gaussian blob and a
crosshair). Per-frame gaussian noise can be enabled via sim_noise_std (counts, default 0
= static frames; noisy frames make live views visibly live but cost bandwidth e.g. over
remote desktop).
"""
from __future__ import annotations
@@ -39,6 +40,14 @@ def make_test_pattern(height: int, width: int, rgb: bool) -> np.ndarray:
return frame
def add_frame_noise(frame: np.ndarray, noise_std: float) -> np.ndarray:
"""Return a copy of the frame with gaussian noise, clipped to uint8."""
if noise_std <= 0:
return frame
noisy = frame.astype(np.float32) + np.random.normal(0.0, noise_std, frame.shape)
return noisy.clip(0, 255).astype(np.uint8)
class SimWebcamViewer(WebcamViewer):
"""WebcamViewer serving a static synthetic frame instead of an MJPEG stream.
@@ -53,16 +62,18 @@ class SimWebcamViewer(WebcamViewer):
num_rotation_90=0,
transpose=False,
sim_shape=(480, 640),
sim_noise_std=0.0,
**kwargs,
) -> None:
self._sim_frame = make_test_pattern(int(sim_shape[0]), int(sim_shape[1]), rgb=True)
self._sim_noise_std = float(sim_noise_std)
super().__init__(
url=url, name=name, num_rotation_90=num_rotation_90, transpose=transpose, **kwargs
)
def _update_loop(self) -> None:
while not self._shutdown_event.is_set():
self.preview.put(self._sim_frame)
self.preview.put(add_frame_noise(self._sim_frame, self._sim_noise_std))
self._shutdown_event.wait(0.2) # 5 Hz
@@ -81,11 +92,12 @@ class _SimIDSSensor:
class _SimIDSBackend:
"""Drop-in replacement for `base_integration.camera.Camera` serving synthetic frames."""
def __init__(self, width: int, height: int, rgb: bool):
def __init__(self, width: int, height: int, rgb: bool, noise_std: float = 0.0):
self.cam = _SimIDSSensor(width, height)
self.force_monochrome = False
self._connected = False
self._rgb = rgb
self._noise_std = float(noise_std)
self._frame = make_test_pattern(height, width, rgb=rgb)
def on_connect(self):
@@ -98,7 +110,7 @@ class _SimIDSBackend:
frame = self._frame
if self.force_monochrome and frame.ndim == 3:
frame = frame[:, :, 0]
return frame
return add_frame_noise(frame, self._noise_std)
def set_camera_rate_limiting(self, enabled: bool):
pass
@@ -120,9 +132,14 @@ class SimIDSCamera(IDSCamera):
num_rotation_90: int = 0,
transpose: bool = False,
force_monochrome: bool = False,
camera_ID: int | None = None,
channels: int | None = None, # legacy OMNY config keys, accepted for compatibility
sim_shape=(1024, 1280),
sim_noise_std=0.0,
**kwargs,
):
if camera_ID is not None and not camera_id:
camera_id = camera_ID
super().__init__(
name=name,
camera_id=camera_id,
@@ -136,5 +153,7 @@ class SimIDSCamera(IDSCamera):
force_monochrome=force_monochrome,
**kwargs,
)
self.cam = _SimIDSBackend(int(sim_shape[1]), int(sim_shape[0]), rgb=not force_monochrome)
self.cam = _SimIDSBackend(
int(sim_shape[1]), int(sim_shape[0]), rgb=not force_monochrome, noise_std=sim_noise_std
)
self.cam.force_monochrome = self._force_monochrome
+133 -29
View File
@@ -45,6 +45,9 @@ class _SimGalilAxis:
self.motor_on = True
self.auto_motor_off = True # fgalil #MOTOFFA switches motors off when idle
self.stppermm = 25600.0
self.encpermm = None # encoder counts per unit; LamNI axes 0-5 read TP in encoder counts
self.pa_target_steps = None # pending absolute target (PA{ax}=)
self.pr_target_steps = None # pending relative target (PR{ax}=)
self.speed_steps = 5.0 * 25600.0
self.pos_steps = 0.0
self._move_start_steps = 0.0
@@ -121,6 +124,17 @@ class SimGalilState:
"allaxref": 0.0,
"axisref": 0.0,
"frmmv": 0.0,
# dialect status variables (LamNI / OMNY); overridable via sim_variables
"swver": 0.0,
"allaxrer": 0.0,
"caperr": 0.0,
"angintf": 0.0,
"tempab": 0.0,
"timeab": 0.0,
"IgNoFol": 0.0,
"grstat": 0.0,
"relpos": 0.0,
"toppin": -30.0,
}
self.digital_inputs = {14: 1.0} # light curtain reads triggered by default
self.digital_outputs = {9: 0.0, 15: 1.0} # gripper closed, lights on
@@ -141,6 +155,8 @@ class SimGalilState:
ax.connected = True
if "stppermm" in kwargs and kwargs["stppermm"] is not None:
ax.stppermm = float(kwargs["stppermm"])
if "encpermm" in kwargs and kwargs["encpermm"] is not None:
ax.encpermm = float(kwargs["encpermm"])
if "velocity" in kwargs and kwargs["velocity"] is not None:
ax.speed_steps = float(kwargs["velocity"]) * ax.stppermm
if "referenced" in kwargs and kwargs["referenced"] is not None:
@@ -150,6 +166,12 @@ class SimGalilState:
if "analog_inputs" in kwargs and kwargs["analog_inputs"]:
for chan, val in kwargs["analog_inputs"].items():
self.analog_inputs[int(chan)] = float(val)
if "digital_outputs" in kwargs and kwargs["digital_outputs"]:
for chan, val in kwargs["digital_outputs"].items():
self.digital_outputs[int(chan)] = float(val)
if "variables" in kwargs and kwargs["variables"]:
for var, val in kwargs["variables"].items():
self.variables[str(var)] = float(val) if not isinstance(val, str) else val
if "digital_inputs" in kwargs and kwargs["digital_inputs"]:
for chan, val in kwargs["digital_inputs"].items():
self.digital_inputs[int(chan)] = float(val)
@@ -157,6 +179,7 @@ class SimGalilState:
ax.limit_low_active = bool(kwargs["limit_low_active"])
if kwargs.get("limit_high_active") is not None:
ax.limit_high_active = bool(kwargs["limit_high_active"])
self.recompute_allaxref()
def thread_active(self, thread_id: int) -> bool:
if thread_id == 0:
@@ -173,10 +196,20 @@ class SimGalilState:
self._thread_deadlines[thread_id] = time.time() + duration
def all_axes_referenced(self) -> bool:
connected = [ax for ax in self.axes if ax.connected]
if not connected:
return False
return all(ax.referenced for ax in connected)
"""Current value of the allaxref DMC variable.
Like on the real controller, allaxref is a plain variable: it is recomputed by
the referencing routines (#FES/#FRM/#REFAX, see recompute_allaxref) but can also
be overwritten directly via 'allaxref=<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):
@@ -202,18 +235,20 @@ class SimGalilState:
ax.start_move(target_mm * ax.stppermm)
def reference_axis(self, axis_index: int, direction: float = 0.0):
"""Simulate #FES / #FRM: short motion, then define zero at the reference."""
"""Simulate #FES / #FRM: define zero at the reference.
The outcome (position 0, referenced, limit switch) is applied synchronously so
that callers polling is_axis_moving and then immediately checking the limit
switch (drive_axis_to_limit) never race the result; only the apparent motion
(thread 0 active window) is time-based.
"""
ax = self.axes[axis_index]
ax.motor_on = True
ax.start_move(ax.position()) # ensure no stale motion
# emulate the drive to the limit / reference with a short, visible motion
self.set_thread_active(0, 0.5)
deadline = time.time() + 0.5
while time.time() < deadline:
time.sleep(0.02)
ax.pos_steps = 0.0
ax.stop_move()
ax.motor_on = True
self.set_thread_active(0, 0.5) # emulate the drive duration
ax.pos_steps = 0.0
ax.referenced = True
self.recompute_allaxref()
if direction > 0:
ax.limit_high_active = True
elif direction < 0:
@@ -270,7 +305,7 @@ class SimGalilState:
if self.variables.get(approach_var, 0.0) == 0.0:
logger.warning(f"[sim galil] transfer requested but {approach_var} not set")
return
self.variables["mntprgs"] = 1.0
self.variables["mntprgs"] = 1.0 if approach_var == "getaprch" else -1.0
self._transfer_abort.clear()
self._transfer_thread = threading.Thread(
target=self._transfer_routine, args=(approach_var,), daemon=True
@@ -303,28 +338,73 @@ class SimGalilSocket(SimSocketBase):
state.digital_outputs[channel] = 1.0 if line.startswith("SB") else 0.0
return ":"
# FUPR: absolute position in counts, e.g. "PAA=25600"
match = re.match(r"^PA([A-H])=(-?[0-9.]+)$", line)
# direct axis commands: PA (absolute, counts), PR (relative, counts)
match = re.match(r"^P([AR])([A-H])=(-?[0-9.]+)$", line)
if match:
axis = ord(match.group(1).lower()) - 97
state.variables[f"_fupr_target_{axis}"] = float(match.group(2))
axis = ord(match.group(2).lower()) - 97
ax = state.axis(axis)
if match.group(1) == "A":
ax.pa_target_steps = float(match.group(3))
else:
ax.pr_target_steps = float(match.group(3))
return ":"
# FUPR: begin motion, e.g. "BGA"
# begin motion, e.g. "BGA": pending absolute target wins, else relative
match = re.match(r"^BG([A-H])$", line)
if match:
axis = ord(match.group(1).lower()) - 97
target = state.variables.get(f"_fupr_target_{axis}", 0.0)
ax = state.axis(axis)
ax.motor_on = True
ax.start_move(target)
if ax.pa_target_steps is not None:
ax.start_move(ax.pa_target_steps)
ax.pa_target_steps = None
elif ax.pr_target_steps is not None:
ax.start_move(ax.position() + ax.pr_target_steps)
ax.pr_target_steps = None
return ":"
# motor servo on/off per axis (OMNY shuttle/dewar handling)
match = re.match(r"^SH([A-H])$", line)
if match:
state.axis(ord(match.group(1).lower()) - 97).motor_on = True
return ":"
match = re.match(r"^MO([A-H])?$", line)
if match:
if match.group(1):
state.axis(ord(match.group(1).lower()) - 97).motor_on = False
else:
for ax in state.axes:
ax.motor_on = False
return ":"
# acceleration / deceleration / speed / limit-disable per axis
match = re.match(r"^(AC|DC|SP|LD)([A-H])=(-?[0-9.]+)$", line)
if match:
if match.group(1) == "SP":
state.axis(ord(match.group(2).lower()) - 97).speed_steps = float(match.group(3))
return ":"
# FUPR: position readback in counts, e.g. "TPA"
match = re.match(r"^T[DP]([A-H])$", line)
match = re.match(r"^T([DP])([A-H])$", line)
if match:
axis = ord(match.group(1).lower()) - 97
return f"{state.axis(axis).position():.4f}"
ax = state.axis(ord(match.group(2).lower()) - 97)
if match.group(1) == "P" and ax.encpermm is not None:
return f"{ax.position() / ax.stppermm * ax.encpermm:.4f}"
return f"{ax.position():.4f}"
if line == "AB" or line.startswith("AB"):
# Abort: stops all motion and all program threads (variables keep their
# values, unlike XQ#STOP which also clears mntprgs/mntmod)
for ax in state.axes:
ax.stop_move()
state._transfer_abort.set()
state._thread_deadlines.clear()
state._tracker_running = False
return ":"
if line == "ST" or re.match(r"^ST[A-H]*$", line):
for ax in state.axes:
ax.stop_move()
return ":"
match = _ASSIGNMENT_RE.match(line)
if match:
@@ -352,6 +432,10 @@ class SimGalilSocket(SimSocketBase):
parsed = float(value)
except ValueError:
parsed = value
if name == "axisref" and index is not None and isinstance(parsed, float):
state.axis(int(index)).referenced = bool(parsed)
state.recompute_allaxref()
return ":"
if index is not None:
state.variables[f"{name}[{index}]"] = parsed
else:
@@ -395,7 +479,7 @@ class SimGalilSocket(SimSocketBase):
if arg.startswith("@AN["):
channel = int(arg[4:].split("]")[0])
return f"{state.analog_inputs.get(channel, 0.0):.4f}"
if arg == "allaxref" or arg == "axisref":
if arg in ("allaxref", "axisref"):
return f"{1.0 if state.all_axes_referenced() else 0.0:.4f}"
if arg.startswith("axisref["):
axis = int(arg.split("[")[1].split("]")[0])
@@ -405,6 +489,10 @@ class SimGalilSocket(SimSocketBase):
if arg.startswith("stppermm["):
axis = int(arg.split("[")[1].split("]")[0])
return f"{state.axis(axis).stppermm:.4f}"
if arg.startswith("encpermm["):
axis = int(arg.split("[")[1].split("]")[0])
ax = state.axis(axis)
return f"{ax.encpermm if ax.encpermm is not None else ax.stppermm:.4f}"
with state.lock:
if arg in state.variables:
val = state.variables[arg]
@@ -423,12 +511,10 @@ class SimGalilSocket(SimSocketBase):
with state.lock:
axis_index = int(state.variables.get("naxis", 0))
direction = float(state.variables.get("ndir", 0.0))
threading.Thread(
target=state.reference_axis, args=(axis_index, direction), daemon=True
).start()
state.reference_axis(axis_index, direction)
return ":"
if routine == "#REFAX": # FUPR
threading.Thread(target=state.reference_axis, args=(0, 0.0), daemon=True).start()
state.reference_axis(0, 0.0)
return ":"
if routine == "#MOTON": # FUPR
state.axis(0).motor_on = True
@@ -461,6 +547,18 @@ class SimGalilSocket(SimSocketBase):
state.set_thread_active(4, 1.0)
state._set_gripper(False)
return ":"
if routine == "#GRSTAT":
# gripper status measurement: report success
state.set_thread_active(4, 0.5)
with state.lock:
state.variables["grstat"] = 1.0
return ":"
if routine == "#CRESET":
with state.lock:
state.variables["confirm"] = 0.0
return ":"
if routine == "#SAFETY":
return ":"
if routine == "#Tracker":
with state.lock:
if state.variables.get("tracken", 0.0) == 1.0 and state.all_axes_referenced():
@@ -480,6 +578,9 @@ def _sim_state_kwargs(
sim_limit_low_active,
sim_limit_high_active,
sign,
sim_encpermm=None,
sim_digital_outputs=None,
sim_variables=None,
) -> dict:
initial_position = None
if sim_initial_position is not None:
@@ -493,6 +594,9 @@ def _sim_state_kwargs(
"digital_inputs": sim_digital_inputs,
"limit_low_active": sim_limit_low_active,
"limit_high_active": sim_limit_high_active,
"encpermm": sim_encpermm,
"digital_outputs": sim_digital_outputs,
"variables": sim_variables,
}
+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
@@ -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
+153
View File
@@ -0,0 +1,153 @@
# Simulated Endstations for cSAXS BEC (flOMNI / LamNI / OMNY)
Status: implemented and validated (2026-07). flOMNI extensively live-tested against a real
BEC deployment; LamNI/OMNY validated by offline harness, live smoke test pending.
## Purpose
Full software development and testing of the flOMNI/LamNI/OMNY control code without
hardware and without a running instrument. The real ophyd device classes, controllers,
scan plugins, GUI widgets, and IPython-client scripts run **unchanged**; only the wire
protocols are simulated. Swapping a device config is the only difference between the
simulated and the real system.
## Architecture: protocol-level socket injection
All three endstations funnel every hardware interaction through socket-based controllers
(`ophyd_devices.utils.controller.Controller`), which accept a `socket_cls` constructor
parameter. The simulation injects `SocketIO`-compatible classes that implement the wire
protocols as in-process state machines:
- Commands are dispatched synchronously inside `put()`; replies go to a queue drained by
`receive()`. Commands that are reply-less on the real hardware must not enqueue replies
(the main desync risk of naive mocks) — reply behavior follows the real server sources.
- Simulation state is shared per (host, port) via `SimStateRegistry`, mirroring the
controller singleton. Device subclasses seed axis parameters at construction.
- The sim device classes are thin subclasses of the real ones whose only jobs are
(a) defaulting `socket_cls` and (b) seeding sim state from `sim_*` deviceConfig keys.
- Cross-device choreography (e.g. flOMNI `feedback_enable_with_reset` touching fsamroy,
fsamx and the ftrackz Galil) runs through the real Python code against each
controller's own sim socket — no logic is duplicated.
Protocol fidelity is grounded in the actual hardware-side sources: `fgalil.dmc`,
`galil_micos_upr.dmc` (FUPR), `lamni.dmc`, `controller1.dmc` (OMNY), flOMNI
`CommunicationServer.cpp`, `LAMNI_server.cpp`, `OMNY_server.cpp`.
## File inventory (all under csaxs_bec unless noted)
- `devices/sim/sim_socket.py``SimSocketBase` + `SimStateRegistry`
- `devices/sim/sim_galil.py` — shared Galil DMC state machine (motion at constant
velocity, threads, referencing `#FES/#FRM/#REFAX`, gripper transfer routines
`#GRGET/#GRPUT` with `confirm` handshake and `atconfir` autoconfirm, `#Tracker`,
`#MNTMODE/#POSMODE`, `#STOP`, `AB`/`ST`, direct axis commands `PA/PR/BG/SH/MO/AC/DC/SP/LD`,
encoder axes `TP`/`encpermm`, dialect variables via `sim_variables`) plus
`SimFlomniGalilMotor`, `SimFuprGalilMotor`
- `devices/sim/sim_smaract.py` — Smaract MCS protocol + `SimSmaractMotor` (serves
flOMNI fosa*, LamNI losa*, OMNY ocs*/oshield)
- `devices/sim/sim_rt_flomni.py` — flOMNI Orchestra server (feedback l0/l1/l2/l3,
tracker T/Ts, scans s/sc/sd/sr, 10-field sample rows, paced by `sim_point_dwell_s`)
- `devices/sim/sim_lamni.py` — LamNI Galil dialect (swver 28, encoder axes 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)
- `fgalil_ophyd.py` and `galil_ophyd.py` (base class, affects LamNI/OMNY): removed the
trailing thread-0 wait in the setpoint `_socket_set` — it blocked `user_setpoint.put()` (holding the
controller lock) for the whole move, so no intermediate readback/progress was possible.
Applies to the real instrument too.
- `ddg_1.py`: `bec_server.scan_server...ScanInfo` import moved under `TYPE_CHECKING`
(typing-only runtime dependency broke against the redeployed BEC layout).
- `flomni.py`: all four scilog sends wrapped in try/except RuntimeError — accounts
without scilog no longer crash `tomo_scan`/alignment/timing reports.
## Delivery policy
Tarballs contain ONLY simulation-package files (`devices/sim/*` except
`simulated_beamline_devices.py`, `device_configs/simulated_omny/*`, generator, harnesses,
this doc) plus the two agreed device-layer fixes (`galil_ophyd.py`, `fgalil_ophyd.py`,
`ddg_1.py`). Fixes to user-maintained files (`flomni.py`, ...) are delivered as diffs
only — never as full files, since the working copies evolve during testing.
## BEC integration lessons (important for future sim devices)
1. **Explicit signatures required**: the device server passes only deviceConfig keys that
appear as named parameters in `inspect.signature(dev_cls)`, and injects
`device_manager` only if named. `*args/**kwargs`-only subclasses silently lose their
entire config.
2. **msgpack strict_map_key**: no integer keys anywhere in deviceConfig — quote channel
numbers (`"1": -1.6`).
3. Hidden second transports exist outside the socket seam (OMNY NETIO power-switch HTTP,
camera MJPEG/pyueye) and need their own stubs.
## Deliberate simulation semantics ("working hardware", no error injection)
- Axes boot **referenced** (real init zeroes at limits; set `sim_referenced: false` to
exercise init procedures) at positions from `in` user params, clamped into limits.
- `allaxref`/`axisref[n]` are genuine writable DMC variables (forcing `allaxref=0` makes
moves refuse, as on hardware); recomputed by referencing routines.
- Constant-velocity motion with per-axis speeds from the DMC `#INIT` tables; RT scans
advance at `sim_point_dwell_s` per position (default 20 ms) so ETA/timing logic is
meaningfully testable.
- Sampled scan rows carry targets + ~2 nm gaussian noise.
- LamNI: the interferometer readback is coupled to the coarse stages — with feedback off,
measured (x, y) = lamni_from_stage_coordinates(lsamx-center, lsamy-center)*1000 um
(geometry constants imported from LamNIFermatScan). This makes
lamni_new_scan_center_interferometer converge. Coupling is configured via
`sim_coarse_coupling` on rtx/rty/rt_positions (generator fills it from the lsamx/lsamy
config). lsamx/lsamy boot at their 'center' user parameters; speeds follow the
lamni.dmc axspeed table.
- A LamNI fermat scan re-enables feedback itself (`feedback_enable_without_reset` in the
scan plugin) — starting a scan with user-disabled feedback therefore works, on the
real system as well as in the sim; it only fails on real hardware when feedback cannot
engage (air on, interferometer error), an error mode deliberately not simulated.
- Referencing (`#FES/#FRM/#REFAX`) applies its outcome synchronously (limit switch,
position 0) while only the apparent motion is time-based, so status polls never race
the result.
- Transfer confirm handshakes are real (GUI + gripper-camera flow works); set
`atconfir=1` on the controller for unattended runs.
- `#GRSTAT` always reports success; NETIO always on; unknown Galil commands reply `:`
and log `[sim galil] unhandled` warnings — grep the device-server log after a session
to find fidelity gaps worth adding (full DMC command-reference emulation was
deliberately not pursued).
- LamNI `sd` reply uses the detector-trigger wording; `sd<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).
+60 -1
View File
@@ -268,6 +268,17 @@ def main(): # noqa: C901
duration = time.time() - t0
check(np.isclose(fsamy.readback.get(), 3.05, atol=1e-3), "fsamy moved to 3.05")
check(duration > 0.3, f"fsamy move at 0.1 mm/s took {duration:.2f} s (constant velocity)")
move_status = fsamy.move(3.0, wait=False)
time.sleep(0.25)
mid_position = fsamy.readback.get()
while not move_status.done:
time.sleep(0.05)
check(
3.0 < mid_position < 3.05, f"intermediate readback during fsamy move ({mid_position:.4f})"
)
check(np.isclose(fsamy.readback.get(), 3.0, atol=1e-3), "fsamy arrived at 3.0")
fsamy.move(3.05, wait=True)
sensor = float(ftransy.controller.socket_put_and_receive("MG@AN[1]").strip())
check(np.isclose(sensor, -1.6), "ftransy gripper sensor voltage reads -1.6")
@@ -289,6 +300,29 @@ def main(): # noqa: C901
curtain = foptz.controller.fosaz_light_curtain_is_triggered()
check(curtain, "fosaz light curtain reads triggered")
# --- 3b. allaxref is a real, writable DMC variable ------------------------------
con1 = fsamx.controller
con1.socket_put_confirmed("allaxref=0")
check(
con1.socket_put_and_receive("MGallaxref").strip() == "0.0000",
"allaxref=0 sticks (MG allaxref reads 0)",
)
move_refused = False
try:
fsamx.move(-1.2, wait=True)
except Exception:
move_refused = True
check(move_refused, "move refused while allaxref=0")
con1.socket_put_confirmed("allaxref=1")
check(con1.all_axes_referenced(), "allaxref=1 restores referenced state")
fsamx.move(-1.14, wait=True)
con1.socket_put_and_receive("AB")
check(
not con1.is_thread_active(5) and not fsamx.controller.is_axis_moving("E", 4),
"AB aborts motion and program threads",
)
# --- 4. smaract ---------------------------------------------------------------
fosax.move(10.5, wait=True)
check(np.isclose(fosax.readback.get(), 10.5, atol=1e-4), "fosax moved to 10.5")
@@ -362,11 +396,36 @@ def main(): # noqa: C901
# --- 8. cameras -----------------------------------------------------------------
webcam = SimWebcamViewer(url="http://sim/video", name="cam_flomni_gripper")
ids_cam = SimIDSCamera(name="cam_xeye", camera_id=11, force_monochrome=True)
ids_cam = SimIDSCamera(name="cam_xeye", camera_id=11, force_monochrome=True, sim_noise_std=4.0)
frame = ids_cam.cam.get_image_data()
check(frame.ndim == 2 and frame.max() > 200, f"IDS sim frame {frame.shape}, max {frame.max()}")
check(webcam._sim_frame.shape == (480, 640, 3), "webcam sim frame 480x640 RGB")
# --- 9. sample storage -----------------------------------------------------------
from csaxs_bec.devices.sim.sim_sample_storage import SimFlomniSampleStorage
samples = SimFlomniSampleStorage(name="flomni_samples")
check(
samples.is_sample_slot_used(0) and samples.is_sample_slot_used(3),
"sample storage boots with samples in slots 0-3",
)
check(not samples.is_sample_in_gripper(), "gripper boots empty")
samples.set_sample_in_gripper(samples.get_sample_name(1))
samples.unset_sample_slot(1)
check(
samples.is_sample_in_gripper() and not samples.is_sample_slot_used(1),
"slot 1 -> gripper bookkeeping",
)
frame_a = ids_cam.cam.get_image_data()
frame_b = ids_cam.cam.get_image_data()
check(not np.array_equal(frame_a, frame_b), "IDS frames are live (noise explicitly enabled)")
static_cam = SimIDSCamera(name="cam_xeye_static", camera_id=12, force_monochrome=True)
check(
np.array_equal(static_cam.cam.get_image_data(), static_cam.cam.get_image_data()),
"IDS frames are static by default (noise disabled)",
)
# --- feedback disable puts stages back to writable -------------------------------
rtx.controller.feedback_disable()
check(not dm.devices["fsamx"].read_only, "fsamx writable again after feedback_disable")
+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.")