Feat/flomni omny lamni sim #257
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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,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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 0–5,
|
||||
air-bearing interlocks) + LAMNI server (J0/J1/J5/J6, J2/J4/J7, As/Ar cap sensors,
|
||||
Ss/Sr sampler, V velocity, 21-field rows)
|
||||
- `devices/sim/sim_omny.py` — OMNY Galil dialect (swver 134, mntax=4, signed mntprgs,
|
||||
tempab/timeab/IgNoFol, grstat/relpos/toppin, `#GRSTAT/#CRESET/#SAFETY`, NETIO power
|
||||
switch stubbed always-on) + OMNY server dialect (J2 six fields, `y`→1 semantics,
|
||||
19-field rows) + patched-EPICS `SimOMNYSampleStorage/Dewar/Temperatures`
|
||||
- `devices/sim/sim_cameras.py` — `SimWebcamViewer` / `SimIDSCamera`: real camera classes
|
||||
with synthetic frame source (crosshair + gaussian blob); optional per-frame noise via
|
||||
`sim_noise_std` (default 0 = static, cheap over remote desktop)
|
||||
- `devices/sim/sim_sample_storage.py` — `SimFlomniSampleStorage` (patched EPICS,
|
||||
seedable via `sim_samples`, default slots 0–3 populated)
|
||||
- `device_configs/simulated_omny/` — **all** simulated configs live here:
|
||||
`simulated_flomni.yaml` (hand-maintained), `simulated_lamni.yaml` and
|
||||
`simulated_omny.yaml` (generated), `simulated_bl_endstation.yaml` (pre-existing)
|
||||
- `bin/generate_simulated_configs.py` — regenerates the LamNI/OMNY configs from
|
||||
`ptycho_lamni.yaml`/`ptycho_omny.yaml` (rerun after real-config changes)
|
||||
- `tests/sim_flomni_harness.py`, `tests/sim_lamni_omny_harness.py` — offline harnesses
|
||||
(no BEC deployment needed): 39 + 26 checks covering moves, referencing, feedback,
|
||||
tracker, scans through the flyers, gripper transfers, storage, cameras
|
||||
|
||||
## Real-code fixes made along the way (separate commits)
|
||||
|
||||
- `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).
|
||||
@@ -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()
|
||||
@@ -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.")
|
||||
Reference in New Issue
Block a user