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