From e179fc1a070ca73a4e8473717e7470553a6ebeb3 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 18 Sep 2025 13:58:14 +0200 Subject: [PATCH 01/54] add gas sensors to config --- .../x01da_experimental_hutch.yaml | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/debye_bec/device_configs/x01da_experimental_hutch.yaml b/debye_bec/device_configs/x01da_experimental_hutch.yaml index f14d1aa..08753f2 100644 --- a/debye_bec/device_configs/x01da_experimental_hutch.yaml +++ b/debye_bec/device_configs/x01da_experimental_hutch.yaml @@ -386,4 +386,64 @@ es_light_toggle: read_pv: "X01DA-EH-LIGHT:TOGGLE" onFailure: retry enabled: true + softwareTrigger: false + +es_gas_sensor_o2: + readoutPriority: baseline + description: ES Gas Sensor O2 + deviceClass: ophyd.EpicsSignalRO + deviceConfig: + read_pv: "X01DA-KIMESSA2:EH-O2" + onFailure: retry + enabled: true + softwareTrigger: false + +es_gas_sensor_h2s: + readoutPriority: baseline + description: ES Gas Sensor H2S + deviceClass: ophyd.EpicsSignalRO + deviceConfig: + read_pv: "X01DA-KIMESSA2:EH-H2S" + onFailure: retry + enabled: true + softwareTrigger: false + +es_gas_sensor_no2: + readoutPriority: baseline + description: ES Gas Sensor NO2 + deviceClass: ophyd.EpicsSignalRO + deviceConfig: + read_pv: "X01DA-KIMESSA2:EH-NO2" + onFailure: retry + enabled: true + softwareTrigger: false + +es_gas_sensor_co: + readoutPriority: baseline + description: ES Gas Sensor CO + deviceClass: ophyd.EpicsSignalRO + deviceConfig: + read_pv: "X01DA-KIMESSA2:EH-CO" + onFailure: retry + enabled: true + softwareTrigger: false + +es_gas_sensor_h2: + readoutPriority: baseline + description: ES Gas Sensor H2 + deviceClass: ophyd.EpicsSignalRO + deviceConfig: + read_pv: "X01DA-KIMESSA2:EH-H2" + onFailure: retry + enabled: true + softwareTrigger: false + +es_gas_sensor_nh3: + readoutPriority: baseline + description: ES Gas Sensor NH3 + deviceClass: ophyd.EpicsSignalRO + deviceConfig: + read_pv: "X01DA-KIMESSA2:EH-NH3" + onFailure: retry + enabled: true softwareTrigger: false \ No newline at end of file From df2961ce8e453187ec36aec3611d30a3947c6180 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 18 Sep 2025 13:58:26 +0200 Subject: [PATCH 02/54] add hutch cameras config --- .../device_configs/x01da_hutch_cameras.yaml | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 debye_bec/device_configs/x01da_hutch_cameras.yaml diff --git a/debye_bec/device_configs/x01da_hutch_cameras.yaml b/debye_bec/device_configs/x01da_hutch_cameras.yaml new file mode 100644 index 0000000..63efbc5 --- /dev/null +++ b/debye_bec/device_configs/x01da_hutch_cameras.yaml @@ -0,0 +1,34 @@ + +################################### +## Hutch Cameras ## +################################### + +hutch_cam_1: + readoutPriority: baseline + description: Hutch Camera 1 + deviceClass: debye_bec.devices.cameras.hutch_cam.HutchCam + deviceConfig: + prefix: "pcp085420" + onFailure: retry + enabled: true + softwareTrigger: false + +hutch_cam_2: + readoutPriority: baseline + description: Hutch Camera 2 + deviceClass: debye_bec.devices.cameras.hutch_cam.HutchCam + deviceConfig: + prefix: "pcp085436" + onFailure: retry + enabled: true + softwareTrigger: false + +hutch_cam_3: + readoutPriority: baseline + description: Hutch Camera 3 + deviceClass: debye_bec.devices.cameras.hutch_cam.HutchCam + deviceConfig: + prefix: "pcp085435" + onFailure: retry + enabled: true + softwareTrigger: false \ No newline at end of file From ed1e5a027fe3b0d956809deaf37a6314293e15f8 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 18 Sep 2025 13:58:38 +0200 Subject: [PATCH 03/54] add hutch cameras to config --- debye_bec/device_configs/x01da_standard_config.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/debye_bec/device_configs/x01da_standard_config.yaml b/debye_bec/device_configs/x01da_standard_config.yaml index 5a0a701..6690f0c 100644 --- a/debye_bec/device_configs/x01da_standard_config.yaml +++ b/debye_bec/device_configs/x01da_standard_config.yaml @@ -70,6 +70,11 @@ xas_config: xrd_config: - !include ./x01da_xrd.yaml +# Commented out because too slow +## Hutch cameras +# hutch_cams: +# - !include ./x01da_hutch_cameras.yaml + ## Remaining experimental hutch es_config: - !include ./x01da_experimental_hutch.yaml \ No newline at end of file From d3dc130f11fad67df743bddb08c7019e3455b4fc Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 18 Sep 2025 13:59:06 +0200 Subject: [PATCH 04/54] uncomment ionization chamber and add pips diode --- debye_bec/device_configs/x01da_xas.yaml | 64 ++++++++++++++----------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/debye_bec/device_configs/x01da_xas.yaml b/debye_bec/device_configs/x01da_xas.yaml index 42e2876..4fb696d 100644 --- a/debye_bec/device_configs/x01da_xas.yaml +++ b/debye_bec/device_configs/x01da_xas.yaml @@ -3,35 +3,45 @@ ## Ionization Chambers ## ################################### -# ic0: -# readoutPriority: baseline -# description: Ionization chamber 0 -# deviceClass: debye_bec.devices.ionization_chambers.ionization_chamber.IonizationChamber0 -# deviceConfig: -# prefix: "X01DA-" -# onFailure: retry -# enabled: true -# softwareTrigger: false +ic0: + readoutPriority: baseline + description: Ionization chamber 0 + deviceClass: debye_bec.devices.ionization_chambers.ionization_chamber.IonizationChamber0 + deviceConfig: + prefix: "X01DA-" + onFailure: retry + enabled: true + softwareTrigger: false -# ic1: -# readoutPriority: baseline -# description: Ionization chamber 1 -# deviceClass: debye_bec.devices.ionization_chambers.ionization_chamber.IonizationChamber1 -# deviceConfig: -# prefix: "X01DA-" -# onFailure: retry -# enabled: true -# softwareTrigger: false +ic1: + readoutPriority: baseline + description: Ionization chamber 1 + deviceClass: debye_bec.devices.ionization_chambers.ionization_chamber.IonizationChamber1 + deviceConfig: + prefix: "X01DA-" + onFailure: retry + enabled: true + softwareTrigger: false -# ic2: -# readoutPriority: baseline -# description: Ionization chamber 2 -# deviceClass: debye_bec.devices.ionization_chambers.ionization_chamber.IonizationChamber2 -# deviceConfig: -# prefix: "X01DA-" -# onFailure: retry -# enabled: true -# softwareTrigger: false +ic2: + readoutPriority: baseline + description: Ionization chamber 2 + deviceClass: debye_bec.devices.ionization_chambers.ionization_chamber.IonizationChamber2 + deviceConfig: + prefix: "X01DA-" + onFailure: retry + enabled: true + softwareTrigger: false + +pips: + readoutPriority: baseline + description: Pips diode + deviceClass: debye_bec.devices.ionization_chambers.ionization_chamber.Pips + deviceConfig: + prefix: "X01DA-" + onFailure: retry + enabled: true + softwareTrigger: false ################################### ## Reference Foil Changer ## From 0261c601ff1cbfa2f6d6c1107b835926ce5b1232 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 18 Sep 2025 13:59:33 +0200 Subject: [PATCH 05/54] uncomment Pilatus-Sample distance --- debye_bec/device_configs/x01da_xrd.yaml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/debye_bec/device_configs/x01da_xrd.yaml b/debye_bec/device_configs/x01da_xrd.yaml index 22ffdba..961cece 100644 --- a/debye_bec/device_configs/x01da_xrd.yaml +++ b/debye_bec/device_configs/x01da_xrd.yaml @@ -86,7 +86,7 @@ pilatus_curtain: softwareTrigger: false pilatus: - readoutPriority: async + readoutPriority: baseline description: Pilatus deviceClass: debye_bec.devices.pilatus.pilatus.Pilatus deviceTags: @@ -97,12 +97,12 @@ pilatus: enabled: true softwareTrigger: true -# sampl_pil: -# readoutPriority: baseline -# description: Sample to pilatus distance -# deviceClass: ophyd.EpicsSignalRO -# deviceConfig: -# read_pv: "X01DA-SAMPL-PIL" -# onFailure: retry -# enabled: true -# softwareTrigger: false +pilatus_smpl: + readoutPriority: baseline + description: Sample to pilatus distance + deviceClass: ophyd.EpicsSignalRO + deviceConfig: + read_pv: "X01DA-ES2-DET:SMPLDIST" + onFailure: retry + enabled: true + softwareTrigger: false \ No newline at end of file From c3bfab20566c08c8bee959658912b05dc5de9221 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 18 Sep 2025 14:00:26 +0200 Subject: [PATCH 06/54] add string representation of signals --- debye_bec/devices/nidaq/nidaq.py | 4 ++++ debye_bec/devices/reffoilchanger.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/debye_bec/devices/nidaq/nidaq.py b/debye_bec/devices/nidaq/nidaq.py index 8d6dcd9..1679e77 100644 --- a/debye_bec/devices/nidaq/nidaq.py +++ b/debye_bec/devices/nidaq/nidaq.py @@ -326,10 +326,14 @@ class NidaqControl(Device): server_status = Cpt(EpicsSignalRO, suffix="NIDAQ-ServerStatus", kind=Kind.config) compression_ratio = Cpt(EpicsSignalRO, suffix="NIDAQ-CompressionRatio", kind=Kind.config) scan_type = Cpt(EpicsSignal, suffix="NIDAQ-ScanType", kind=Kind.config) + scan_type_string = Cpt(EpicsSignal, suffix="NIDAQ-ScanType", kind=Kind.config, string=True) sampling_rate = Cpt(EpicsSignal, suffix="NIDAQ-SamplingRateRequested", kind=Kind.config) + sampling_rate_string = Cpt(EpicsSignal, suffix="NIDAQ-SamplingRateRequested", kind=Kind.config, string=True) scan_duration = Cpt(EpicsSignal, suffix="NIDAQ-SamplingDuration", kind=Kind.config) readout_range = Cpt(EpicsSignal, suffix="NIDAQ-ReadoutRange", kind=Kind.config) + readout_range_string = Cpt(EpicsSignal, suffix="NIDAQ-ReadoutRange", kind=Kind.config, string=True) encoder_factor = Cpt(EpicsSignal, suffix="NIDAQ-EncoderFactor", kind=Kind.config) + encoder_factor_string = Cpt(EpicsSignal, suffix="NIDAQ-EncoderFactor", kind=Kind.config, string=True) stop_call = Cpt(EpicsSignal, suffix="NIDAQ-Stop", kind=Kind.config) power = Cpt(EpicsSignal, suffix="NIDAQ-Power", kind=Kind.config) heartbeat = Cpt(EpicsSignal, suffix="NIDAQ-Heartbeat", kind=Kind.config, auto_monitor=True) diff --git a/debye_bec/devices/reffoilchanger.py b/debye_bec/devices/reffoilchanger.py index d22c997..b59970d 100644 --- a/debye_bec/devices/reffoilchanger.py +++ b/debye_bec/devices/reffoilchanger.py @@ -52,9 +52,15 @@ class Reffoilchanger(PSIDeviceBase): status = Cpt( EpicsSignal, suffix="ES2-REF:SELN-FilterState-ENUM_RBV", kind="config", doc="Status" ) + status_string = Cpt( + EpicsSignal, suffix="ES2-REF:SELN-FilterState-ENUM_RBV", kind="config", doc="Status", string=True + ) op_mode = Cpt( EpicsSignalWithRBV, suffix="ES2-REF:SELN-OpMode-ENUM", kind="config", doc="Status" ) + op_mode_string = Cpt( + EpicsSignalWithRBV, suffix="ES2-REF:SELN-OpMode-ENUM", kind="config", doc="Status", string=True + ) ref_set = Cpt(EpicsSignal, suffix="ES2-REF:SELN-SET", kind="config", doc="Requested reference") ref_rb = Cpt( EpicsSignalRO, suffix="ES2-REF:SELN-RB", kind="config", doc="Currently set reference" From d8383d3b73eb1a8bb04a772b1d14b7deda00f304 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 18 Sep 2025 14:01:05 +0200 Subject: [PATCH 07/54] add string representation of signals. add Pips class/device --- .../ionization_chambers/ionization_chamber.py | 123 ++++++++++++++++-- 1 file changed, 115 insertions(+), 8 deletions(-) diff --git a/debye_bec/devices/ionization_chambers/ionization_chamber.py b/debye_bec/devices/ionization_chambers/ionization_chamber.py index 52a6a78..25b81d8 100644 --- a/debye_bec/devices/ionization_chambers/ionization_chamber.py +++ b/debye_bec/devices/ionization_chambers/ionization_chamber.py @@ -33,22 +33,24 @@ class EpicsSignalSplit(EpicsSignal): class GasMixSetupControl(Device): """GasMixSetup Control for Inonization Chamber 0""" - gas1_req = Cpt(EpicsSignalWithRBV, suffix="Gas1Req", kind="config", doc="Gas 1 requirement") + gas1_req = Cpt(EpicsSignalWithRBV, suffix="Gas1Req", kind="omitted", doc="Gas 1 requirement") conc1_req = Cpt( - EpicsSignalWithRBV, suffix="Conc1Req", kind="config", doc="Concentration 1 requirement" + EpicsSignalWithRBV, suffix="Conc1Req", kind="omitted", doc="Concentration 1 requirement" ) - gas2_req = Cpt(EpicsSignalWithRBV, suffix="Gas2Req", kind="config", doc="Gas 2 requirement") + gas2_req = Cpt(EpicsSignalWithRBV, suffix="Gas2Req", kind="omitted", doc="Gas 2 requirement") conc2_req = Cpt( - EpicsSignalWithRBV, suffix="Conc2Req", kind="config", doc="Concentration 2 requirement" + EpicsSignalWithRBV, suffix="Conc2Req", kind="omitted", doc="Concentration 2 requirement" ) press_req = Cpt( - EpicsSignalWithRBV, suffix="PressReq", kind="config", doc="Pressure requirement" + EpicsSignalWithRBV, suffix="PressReq", kind="omitted", doc="Pressure requirement" ) fill = Cpt(EpicsSignal, suffix="Fill", kind="config", doc="Fill the chamber") status = Cpt(EpicsSignalRO, suffix="Status", kind="config", doc="Status") gas1 = Cpt(EpicsSignalRO, suffix="Gas1", kind="config", doc="Gas 1") + gas1_string = Cpt(EpicsSignalRO, suffix="Gas1", kind="config", doc="Gas 1", string=True) conc1 = Cpt(EpicsSignalRO, suffix="Conc1", kind="config", doc="Concentration 1") gas2 = Cpt(EpicsSignalRO, suffix="Gas2", kind="config", doc="Gas 2") + gas2_string = Cpt(EpicsSignalRO, suffix="Gas2", kind="config", doc="Gas 2", string=True) conc2 = Cpt(EpicsSignalRO, suffix="Conc2", kind="config", doc="Concentration 2") press = Cpt(EpicsSignalRO, suffix="PressTransm", kind="config", doc="Current Pressure") @@ -84,10 +86,25 @@ class IonizationChamber0(PSIDeviceBase): (f"ES:AMP5004:cFilter{num}_ENUM"), {"kind": "config", "doc": f"Filter of ch{num} -> IC{num-1}"}, ), + "cOnOff_string": ( + EpicsSignal, + (f"ES:AMP5004.cOnOff{num}"), + {"kind": "config", "doc": f"Enable ch{num} -> IC{num-1}", "string": True}, + ), + "cGain_ENUM_string": ( + EpicsSignalWithRBV, + (f"ES:AMP5004:cGain{num}_ENUM"), + {"kind": "config", "doc": f"Gain of ch{num} -> IC{num-1}", "string": True}, + ), + "cFilter_ENUM_string": ( + EpicsSignalWithRBV, + (f"ES:AMP5004:cFilter{num}_ENUM"), + {"kind": "config", "doc": f"Filter of ch{num} -> IC{num-1}", "string": True}, + ), } amp = Dcpt(amp_signals) gmes = Cpt(GasMixSetupControl, suffix=f"ES-GMES:IC{num-1}") - gmes_status = Cpt(EpicsSignalRO, suffix="ES-GMES:StatusMsg0", kind="config", doc="Status") + gmes_status_msg = Cpt(EpicsSignalRO, suffix="ES-GMES:StatusMsg0", kind="config", doc="Status") hv = Cpt(HighVoltageSuppliesControl, suffix=f"ES1-IC{num-1}:") hv_en_signals = { "ext_ena": ( @@ -275,10 +292,25 @@ class IonizationChamber1(IonizationChamber0): (f"ES:AMP5004:cFilter{num}_ENUM"), {"kind": "config", "doc": f"Filter of ch{num} -> IC{num-1}"}, ), + "cOnOff_string": ( + EpicsSignal, + (f"ES:AMP5004.cOnOff{num}"), + {"kind": "config", "doc": f"Enable ch{num} -> IC{num-1}", "string": True}, + ), + "cGain_ENUM_string": ( + EpicsSignalWithRBV, + (f"ES:AMP5004:cGain{num}_ENUM"), + {"kind": "config", "doc": f"Gain of ch{num} -> IC{num-1}", "string": True}, + ), + "cFilter_ENUM_string": ( + EpicsSignalWithRBV, + (f"ES:AMP5004:cFilter{num}_ENUM"), + {"kind": "config", "doc": f"Filter of ch{num} -> IC{num-1}", "string": True}, + ), } amp = Dcpt(amp_signals) gmes = Cpt(GasMixSetupControl, suffix=f"ES-GMES:IC{num-1}") - gmes_status = Cpt(EpicsSignalRO, suffix="ES-GMES:StatusMsg0", kind="config", doc="Status") + gmes_status_msg = Cpt(EpicsSignalRO, suffix="ES-GMES:StatusMsg0", kind="config", doc="Status") hv = Cpt(HighVoltageSuppliesControl, suffix=f"ES2-IC{num-1}:") hv_en_signals = { "ext_ena": ( @@ -311,10 +343,25 @@ class IonizationChamber2(IonizationChamber0): (f"ES:AMP5004:cFilter{num}_ENUM"), {"kind": "config", "doc": f"Filter of ch{num} -> IC{num-1}"}, ), + "cOnOff_string": ( + EpicsSignal, + (f"ES:AMP5004.cOnOff{num}"), + {"kind": "config", "doc": f"Enable ch{num} -> IC{num-1}", "string": True}, + ), + "cGain_ENUM_string": ( + EpicsSignalWithRBV, + (f"ES:AMP5004:cGain{num}_ENUM"), + {"kind": "config", "doc": f"Gain of ch{num} -> IC{num-1}", "string": True}, + ), + "cFilter_ENUM_string": ( + EpicsSignalWithRBV, + (f"ES:AMP5004:cFilter{num}_ENUM"), + {"kind": "config", "doc": f"Filter of ch{num} -> IC{num-1}", "string": True}, + ), } amp = Dcpt(amp_signals) gmes = Cpt(GasMixSetupControl, suffix=f"ES-GMES:IC{num-1}") - gmes_status = Cpt(EpicsSignalRO, suffix="ES-GMES:StatusMsg0", kind="config", doc="Status") + gmes_status_msg = Cpt(EpicsSignalRO, suffix="ES-GMES:StatusMsg0", kind="config", doc="Status") hv = Cpt(HighVoltageSuppliesControl, suffix=f"ES2-IC{num-1}:") hv_en_signals = { "ext_ena": ( @@ -325,3 +372,63 @@ class IonizationChamber2(IonizationChamber0): "ena": (EpicsSignal, "ES2-IC12:HV-Ena", {"kind": "config", "doc": "Enable signal of HV"}), } hv_en = Dcpt(hv_en_signals) + +class Pips(IonizationChamber0): + """Pips, prefix should be 'X01DA-'.""" + + USER_ACCESS = ["set_gain", "set_filter"] + + num = 4 + amp_signals = { + "cOnOff": ( + EpicsSignal, + (f"ES:AMP5004.cOnOff{num}"), + {"kind": "config", "doc": f"Enable ch{num} -> IC{num-1}"}, + ), + "cGain_ENUM": ( + EpicsSignalWithRBV, + (f"ES:AMP5004:cGain{num}_ENUM"), + {"kind": "config", "doc": f"Gain of ch{num} -> IC{num-1}"}, + ), + "cFilter_ENUM": ( + EpicsSignalWithRBV, + (f"ES:AMP5004:cFilter{num}_ENUM"), + {"kind": "config", "doc": f"Filter of ch{num} -> IC{num-1}"}, + ), + "cOnOff_string": ( + EpicsSignal, + (f"ES:AMP5004.cOnOff{num}"), + {"kind": "config", "doc": f"Enable ch{num} -> IC{num-1}", "string": True}, + ), + "cGain_ENUM_string": ( + EpicsSignalWithRBV, + (f"ES:AMP5004:cGain{num}_ENUM"), + {"kind": "config", "doc": f"Gain of ch{num} -> IC{num-1}", "string": True}, + ), + "cFilter_ENUM_string": ( + EpicsSignalWithRBV, + (f"ES:AMP5004:cFilter{num}_ENUM"), + {"kind": "config", "doc": f"Filter of ch{num} -> IC{num-1}", "string": True}, + ), + } + amp = Dcpt(amp_signals) + gmes = None + gmes_status_msg = None + hv = None + hv_en_signals = None + hv_en = None + + @typechecked + def set_hv(self, *_) -> None: + """Not available for the PIPS""" + return None + + @typechecked + def set_grid(self, *_) -> None: + """Not available for the PIPS""" + return None + + @typechecked + def fill(self, *_) -> None: + """Not available for the PIPS""" + return None From 737761321372e7c4b672400a1c97494d7424a73d Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 18 Sep 2025 14:01:26 +0200 Subject: [PATCH 08/54] add config signals --- debye_bec/devices/cameras/basler_cam.py | 12 ++++++- debye_bec/devices/cameras/prosilica_cam.py | 12 ++++++- debye_bec/devices/pilatus/pilatus.py | 42 +++++++++++++--------- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/debye_bec/devices/cameras/basler_cam.py b/debye_bec/devices/cameras/basler_cam.py index fbf0477..5c5f7ce 100644 --- a/debye_bec/devices/cameras/basler_cam.py +++ b/debye_bec/devices/cameras/basler_cam.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from ophyd import ADBase +from ophyd import ADBase, EpicsSignalRO from ophyd import ADComponent as ADCpt from ophyd import Component as Cpt from ophyd_devices import PreviewSignal @@ -20,6 +20,16 @@ if TYPE_CHECKING: # pragma: no cover class BaslerCamBase(ADBase): """BaslerCam Base class.""" + cam_detector_state_string = Cpt(EpicsSignalRO, suffix="cam1:DetectorState_RBV", string=True) + + _default_configuration_attrs = [ + 'cam1.acquire_time', + 'cam1.detector_state', + 'cam_detector_state_string', + 'cam1.gain', + 'cam1.model', + ] + cam1 = ADCpt(AravisDetectorCam, "cam1:") image1 = ADCpt(ImagePlugin_V35, "image1:") diff --git a/debye_bec/devices/cameras/prosilica_cam.py b/debye_bec/devices/cameras/prosilica_cam.py index 69846dd..92f842d 100644 --- a/debye_bec/devices/cameras/prosilica_cam.py +++ b/debye_bec/devices/cameras/prosilica_cam.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from ophyd import ADBase +from ophyd import ADBase, EpicsSignalRO from ophyd import ADComponent as ADCpt from ophyd import Component as Cpt from ophyd_devices import PreviewSignal @@ -20,6 +20,16 @@ if TYPE_CHECKING: # pragma: no cover class ProsilicaCamBase(ADBase): """Base class for Prosilica cameras.""" + cam_detector_state_string = Cpt(EpicsSignalRO, suffix="cam1:DetectorState_RBV", string=True) + + _default_configuration_attrs = [ + 'cam1.acquire_time', + 'cam1.detector_state', + 'cam_detector_state_string', + 'cam1.gain', + 'cam1.model', + ] + cam1 = ADCpt(ProsilicaDetectorCam, "cam1:") image1 = ADCpt(ImagePlugin_V35, "image1:") diff --git a/debye_bec/devices/pilatus/pilatus.py b/debye_bec/devices/pilatus/pilatus.py index 3db1c4c..e536f0e 100644 --- a/debye_bec/devices/pilatus/pilatus.py +++ b/debye_bec/devices/pilatus/pilatus.py @@ -6,13 +6,13 @@ import enum import threading import time import traceback -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Tuple import numpy as np from bec_lib.file_utils import get_full_path from bec_lib.logger import bec_logger from ophyd import Component as Cpt -from ophyd import EpicsSignal, Kind +from ophyd import EpicsSignal, EpicsSignalRO, Kind from ophyd.areadetector.cam import ADBase, PilatusDetectorCam from ophyd.areadetector.plugins import HDF5Plugin_V22 as HDF5Plugin from ophyd.areadetector.plugins import ImagePlugin_V22 as ImagePlugin @@ -145,6 +145,19 @@ class Pilatus(PSIDeviceBase, ADBase): # USER_ACCESS = ["start_live_mode", "stop_live_mode"] + cam_gain_menu_string = Cpt(EpicsSignalRO, suffix='cam1:GainMenu', string=True) + + _default_configuration_attrs = [ + 'cam.threshold_energy', + 'cam.threshold_auto_apply', + 'cam.gain_menu', + 'cam_gain_menu_string', + 'cam.pixel_cut_off', + 'cam.acquire_time', + 'cam.num_exposures', + 'cam.model', + ] + cam = Cpt(PilatusDetectorCam, "cam1:") hdf = Cpt(HDF5Plugin, "HDF1:") image1 = Cpt(ImagePlugin, "image1:") @@ -203,22 +216,11 @@ class Pilatus(PSIDeviceBase, ADBase): PreviewSignal, name="preview", ndim=2, - num_rotation_90=0, # Check this + num_rotation_90=3, doc="Preview signal for the Pilatus Detector", ) file_event = Cpt(FileEventSignal, name="file_event") - @property - def baseline_signals(self): - """Define baseline signals""" - return [ - self.cam.acquire_time, - self.cam.num_exposures, - self.cam.threshold_energy, - self.cam.gain_menu, - self.cam.pixel_cut_off, - ] - def __init__( self, *, @@ -366,7 +368,7 @@ class Pilatus(PSIDeviceBase, ADBase): status = status_acquire & status_writing & status_cam_server return status - def _calculate_trigger(self, scan_msg: ScanStatusMessage): + def _calculate_trigger(self, scan_msg: ScanStatusMessage) -> Tuple[float, float]: self._update_scan_parameter() total_osc = 0 total_trig_lo = 0 @@ -427,7 +429,7 @@ class Pilatus(PSIDeviceBase, ADBase): if calc_duration >= self.scan_parameter.scan_duration: break - return total_trig_lo + total_trig_hi + return total_trig_lo, total_trig_hi ######################################## # Beamline Specific Implementations # @@ -480,6 +482,14 @@ class Pilatus(PSIDeviceBase, ADBase): """ # self.stop_live_mode() # Make sure that live mode is stopped if scan runs + # If user has activated alignment mode on qt panel, switch back to multitrigger and stop acquisition + if self.cam.trigger_mode.get() != TRIGGERMODE.MULT_TRIGGER.value: + self.cam.trigger_mode.set(TRIGGERMODE.MULT_TRIGGER.value).wait(5) + if self.cam.acquire.get() == ACQUIREMODE.ACQUIRING.value: + self.cam.acquire.put(0) + status_cam = CompareStatus(self.cam.acquire, ACQUIREMODE.DONE.value) + status_cam.wait(timeout=5) + scan_msg: ScanStatusMessage = self.scan_info.msg if scan_msg.scan_name in self.xas_xrd_scan_names: self._update_scan_parameter() From faeb991b75d67cbe8c1a6b58471d29d9fa5bb428 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 18 Sep 2025 14:01:41 +0200 Subject: [PATCH 09/54] create hutch camera class --- debye_bec/devices/cameras/hutch_cam.py | 79 ++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 debye_bec/devices/cameras/hutch_cam.py diff --git a/debye_bec/devices/cameras/hutch_cam.py b/debye_bec/devices/cameras/hutch_cam.py new file mode 100644 index 0000000..633b8dc --- /dev/null +++ b/debye_bec/devices/cameras/hutch_cam.py @@ -0,0 +1,79 @@ +"""EH Hutch Cameras""" + +from __future__ import annotations + +import cv2 +import threading +from typing import TYPE_CHECKING + +from bec_lib.logger import bec_logger +from bec_lib.file_utils import get_full_path +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase +from ophyd_devices import DeviceStatus + +if TYPE_CHECKING: # pragma: no cover + from bec_lib.devicemanager import ScanInfo + from bec_lib.messages import ScanStatusMessage + +logger = bec_logger.logger + +CAM_USERNAME = "camera_user" +CAM_PASSWORD = "camera_user1" +CAM_PORT = 554 + +class HutchCam(PSIDeviceBase): + """Class for the Hutch Cameras""" + + # image = Cpt(Signal, name='image', kind='config') + + def __init__(self, *, name: str, prefix: str = "", scan_info: ScanInfo | None = None, **kwargs): + super().__init__(name=name, scan_info=scan_info, **kwargs) + + self.hostname = prefix + self.status = None + + # pylint: disable=E1101 + def on_connected(self) -> None: + """ + Called after the device is connected and its signals are connected. + Default values for signals should be set here. + """ + rtsp_url = f"rtsp://{CAM_USERNAME}:{CAM_PASSWORD}@{self.hostname}.psi.ch:{CAM_PORT}/rtpstream/config1" + cap = cv2.VideoCapture(f"{rtsp_url}?tcp") + if not cap.isOpened(): + logger.error(self, "Connection Failed", "Could not connect to the camera stream.") + return + cap.release() + + def on_stage(self) -> DeviceStatus: + """Called while staging the device.""" + + scan_msg: ScanStatusMessage = self.scan_info.msg + file_path = get_full_path(scan_msg, name='hutch_cam_' + self.hostname).removesuffix('h5') + + self.status = DeviceStatus(self) + + thread = threading.Thread(target=self._save_picture, args=(file_path, self.status), daemon=True) + thread.start() + + return self.status + + def _save_picture(self, file_path, status): + try: + logger.info(f'Capture from camera {self.hostname}') + rtsp_url = f"rtsp://{CAM_USERNAME}:{CAM_PASSWORD}@{self.hostname}.psi.ch:{CAM_PORT}/rtpstream/config1" + cap = cv2.VideoCapture(f"{rtsp_url}?tcp") + if not cap.isOpened(): + logger.error("Connection Failed", "Could not connect to the camera stream.") + return + logger.info(f'Connection to camera {self.hostname} established') + ret, frame = cap.readAsync() + cap.release() + if not ret: + logger.error("Capture Failed", "Failed to capture image from camera.") + return + cv2.imwrite(file_path + 'png', frame) + status.set_finished() + logger.info(f'Capture from camera {self.hostname} done') + except Exception as e: + status.set_exception(e) From 1c0c9ad53ef78d671bb554861163f6a028a44861 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 18 Sep 2025 14:06:44 +0200 Subject: [PATCH 10/54] add opencv dependency for hutch cameras --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 10cab05..f34c291 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Topic :: Scientific/Engineering", ] -dependencies = ["numpy", "scipy", "bec_lib", "h5py", "ophyd_devices"] +dependencies = ["numpy", "scipy", "bec_lib", "h5py", "ophyd_devices", "opencv-python==4.11.0.86"] [project.optional-dependencies] dev = [ From e9e7d84e603cea60c12b27545a0cb2feb28ef863 Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 8 Oct 2025 15:44:15 +0200 Subject: [PATCH 11/54] reworked function to get rid of (potentionally infinite) loop. --- debye_bec/devices/pilatus/pilatus.py | 91 +++++++++++----------------- 1 file changed, 37 insertions(+), 54 deletions(-) diff --git a/debye_bec/devices/pilatus/pilatus.py b/debye_bec/devices/pilatus/pilatus.py index e536f0e..2a7668f 100644 --- a/debye_bec/devices/pilatus/pilatus.py +++ b/debye_bec/devices/pilatus/pilatus.py @@ -371,63 +371,46 @@ class Pilatus(PSIDeviceBase, ADBase): def _calculate_trigger(self, scan_msg: ScanStatusMessage) -> Tuple[float, float]: self._update_scan_parameter() total_osc = 0 + calc_duration = 0 total_trig_lo = 0 total_trig_hi = 0 - calc_duration = 0 - n_trig_lo = 1 - n_trig_hi = 1 - init_lo = 1 - init_hi = 1 - lo_done = 0 - hi_done = 0 - if not self.scan_parameter.break_enable_low: - lo_done = 1 - if not self.scan_parameter.break_enable_high: - hi_done = 1 - start_time = time.time() - while True: - # TODO, we should not use infinite loops, for now let's add the escape Timeout of 20s, but should eventually be reviewed. - if time.time() - start_time > 20: - raise RuntimeError( - f"Calculating the number of triggers for scan {scan_msg.scan_name} took more than 20 seconds, aborting." - ) + # Switching high/low is intended as angle is inverse to energy and settings in BEC are always in energy + loc_break_enable_low = self.scan_parameter.break_enable_high + loc_break_time_low = self.scan_parameter.break_time_high + loc_cycle_low = self.scan_parameter.cycle_high + loc_break_enable_high = self.scan_parameter.break_enable_low + loc_break_time_high = self.scan_parameter.break_time_low + loc_cycle_high = self.scan_parameter.cycle_low + + if not loc_break_enable_low: + loc_break_time_low = 0 + loc_cycle_low = 1 + if not loc_break_enable_high: + loc_break_time_high = 0 + loc_cycle_high = 1 + + total_osc = self.scan_parameter.scan_duration / ( + self.scan_parameter.scan_time + + loc_break_time_low / (2 * loc_cycle_low) + + loc_break_time_high / (2 * loc_cycle_high) + ) + total_osc = np.ceil(total_osc) + total_osc = total_osc + total_osc % 2 # round up to the next even number + + if loc_break_enable_low: + total_trig_lo = np.floor(total_osc / (2 * loc_cycle_low)) + if loc_break_enable_high: + total_trig_hi = np.floor(total_osc / (2 * loc_cycle_high)) + calc_duration = total_osc * self.scan_parameter.scan_time + total_trig_lo * loc_break_time_low + total_trig_hi * loc_break_time_high + + if calc_duration < self.scan_parameter.scan_duration: + # Due to inaccuracy in formula, this can happen, we then need to manually add two oscillations and recalculate the triggers total_osc = total_osc + 2 - calc_duration = calc_duration + 2 * self.scan_parameter.scan_time - - if self.scan_parameter.break_enable_low and n_trig_lo >= self.scan_parameter.cycle_low: - n_trig_lo = 1 - calc_duration = calc_duration + self.scan_parameter.break_time_low - if init_lo: - lo_done = 1 - init_lo = 0 - else: - n_trig_lo += 1 - - if ( - self.scan_parameter.break_enable_high - and n_trig_hi >= self.scan_parameter.cycle_high - ): - n_trig_hi = 1 - calc_duration = calc_duration + self.scan_parameter.break_time_high - if init_hi: - hi_done = 1 - init_hi = 0 - else: - n_trig_hi += 1 - - if lo_done and hi_done: - n = np.floor(self.scan_parameter.scan_duration / calc_duration) - total_osc = total_osc * n - if self.scan_parameter.break_enable_low: - total_trig_lo = n + 1 - if self.scan_parameter.break_enable_high: - total_trig_hi = n + 1 - calc_duration = calc_duration * n - lo_done = 0 - hi_done = 0 - - if calc_duration >= self.scan_parameter.scan_duration: - break + if loc_break_enable_low: + total_trig_lo = np.floor(total_osc / (2 * loc_cycle_low)) + if loc_break_enable_high: + total_trig_hi = np.floor(total_osc / (2 * loc_cycle_high)) + calc_duration = total_osc * self.scan_parameter.scan_time + total_trig_lo * loc_break_time_low + total_trig_hi * loc_break_time_high return total_trig_lo, total_trig_hi From 37a268fe7b822073696d41c2fa3d60d35b8538c3 Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 20 Oct 2025 12:58:31 +0200 Subject: [PATCH 12/54] add additional CI channels for NIDAQ. Add enable PV for dead time correction --- debye_bec/devices/nidaq/nidaq.py | 131 +++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/debye_bec/devices/nidaq/nidaq.py b/debye_bec/devices/nidaq/nidaq.py index 1679e77..5d4c77b 100644 --- a/debye_bec/devices/nidaq/nidaq.py +++ b/debye_bec/devices/nidaq/nidaq.py @@ -146,6 +146,76 @@ class NidaqControl(Device): doc="EPICS counter input 7", auto_monitor=True, ) + ci8 = Cpt( + EpicsSignalRO, + suffix="NIDAQ-CI8", + kind=Kind.normal, + doc="EPICS counter input 8", + auto_monitor=True, + ) + ci9 = Cpt( + EpicsSignalRO, + suffix="NIDAQ-CI9", + kind=Kind.normal, + doc="EPICS counter input 9", + auto_monitor=True, + ) + ci10 = Cpt( + EpicsSignalRO, + suffix="NIDAQ-CI10", + kind=Kind.normal, + doc="EPICS counter input 0", + auto_monitor=True, + ) + ci11 = Cpt( + EpicsSignalRO, + suffix="NIDAQ-CI11", + kind=Kind.normal, + doc="EPICS counter input 1", + auto_monitor=True, + ) + ci12 = Cpt( + EpicsSignalRO, + suffix="NIDAQ-CI12", + kind=Kind.normal, + doc="EPICS counter input 2", + auto_monitor=True, + ) + ci13 = Cpt( + EpicsSignalRO, + suffix="NIDAQ-CI13", + kind=Kind.normal, + doc="EPICS counter input 3", + auto_monitor=True, + ) + ci14 = Cpt( + EpicsSignalRO, + suffix="NIDAQ-CI14", + kind=Kind.normal, + doc="EPICS counter input 4", + auto_monitor=True, + ) + ci15 = Cpt( + EpicsSignalRO, + suffix="NIDAQ-CI15", + kind=Kind.normal, + doc="EPICS counter input 5", + auto_monitor=True, + ) + ci16 = Cpt( + EpicsSignalRO, + suffix="NIDAQ-CI16", + kind=Kind.normal, + doc="EPICS counter input 6", + auto_monitor=True, + ) + ci17 = Cpt( + EpicsSignalRO, + suffix="NIDAQ-CI17", + kind=Kind.normal, + doc="EPICS counter input 7", + auto_monitor=True, + ) di0 = Cpt( EpicsSignalRO, @@ -275,6 +345,36 @@ class NidaqControl(Device): ci7_mean = Cpt( SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 7, MEAN" ) + ci8_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 8, MEAN" + ) + ci9_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 9, MEAN" + ) + ci10_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 10, MEAN" + ) + ci11_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 11, MEAN" + ) + ci12_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 12, MEAN" + ) + ci13_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 13, MEAN" + ) + ci14_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 14, MEAN" + ) + ci15_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 15, MEAN" + ) + ci16_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 16, MEAN" + ) + ci17_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 17, MEAN" + ) ci0_std_dev = Cpt( SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 0. STD" @@ -300,6 +400,36 @@ class NidaqControl(Device): ci7_std_dev = Cpt( SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 7. STD" ) + ci8_std_dev = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 8. STD" + ) + ci9_std_dev = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 9. STD" + ) + ci10_std_dev = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 10. STD" + ) + ci11_std_dev = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 11. STD" + ) + ci12_std_dev = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 12. STD" + ) + ci13_std_dev = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 13. STD" + ) + ci14_std_dev = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 14. STD" + ) + ci15_std_dev = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 15. STD" + ) + ci16_std_dev = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 16. STD" + ) + ci17_std_dev = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 17. STD" + ) xas_timestamp = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XAS timestamp") @@ -320,6 +450,7 @@ class NidaqControl(Device): ### Control PVs ### enable_compression = Cpt(EpicsSignal, suffix="NIDAQ-EnableRLE", kind=Kind.config) + enable_dead_time_correction = Cpt(EpicsSignal, suffix="NIDAQ-EnableDTC", kind=Kind.config) kickoff_call = Cpt(EpicsSignal, suffix="NIDAQ-Kickoff", kind=Kind.config) stage_call = Cpt(EpicsSignal, suffix="NIDAQ-Stage", kind=Kind.config) state = Cpt(EpicsSignal, suffix="NIDAQ-FSMState", kind=Kind.config, auto_monitor=True) From 062df3171bc153e521814018cbbd39e6b0419294 Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 20 Oct 2025 14:27:16 +0200 Subject: [PATCH 13/54] add additional signals for xrd tigger information --- debye_bec/devices/nidaq/nidaq.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/debye_bec/devices/nidaq/nidaq.py b/debye_bec/devices/nidaq/nidaq.py index 5d4c77b..c62d35a 100644 --- a/debye_bec/devices/nidaq/nidaq.py +++ b/debye_bec/devices/nidaq/nidaq.py @@ -434,8 +434,10 @@ class NidaqControl(Device): xas_timestamp = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XAS timestamp") xrd_timestamp = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XRD timestamp") - + xrd_angle = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XRD angle") xrd_energy = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XRD energy") + xrd_ai0_mean = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XRD ai0 mean") + xrd_ai0_std_dev = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XRD ai0 std dev") di0_max = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream digital input 0, MAX") di1_max = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream digital input 1, MAX") From fd1626fbcdfc4d4e0d873ae99816fcc7e6fc2f95 Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 17 Nov 2025 11:24:13 +0100 Subject: [PATCH 14/54] nidaq improvement on_stage --- debye_bec/device_configs/x01da_standard_config.yaml | 6 +++--- debye_bec/devices/nidaq/nidaq.py | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/debye_bec/device_configs/x01da_standard_config.yaml b/debye_bec/device_configs/x01da_standard_config.yaml index 6690f0c..3490ae0 100644 --- a/debye_bec/device_configs/x01da_standard_config.yaml +++ b/debye_bec/device_configs/x01da_standard_config.yaml @@ -51,7 +51,7 @@ optics_config: ## Experimental Hutch ## ################################### -## NIDAQ +# ## NIDAQ nidaq: readoutPriority: monitored description: NIDAQ backend for data reading for debye scans @@ -67,8 +67,8 @@ xas_config: - !include ./x01da_xas.yaml ## XRD (Pilatus, pinhole, beamstop) -xrd_config: - - !include ./x01da_xrd.yaml +#xrd_config: +# - !include ./x01da_xrd.yaml # Commented out because too slow ## Hutch cameras diff --git a/debye_bec/devices/nidaq/nidaq.py b/debye_bec/devices/nidaq/nidaq.py index c62d35a..3cd2f58 100644 --- a/debye_bec/devices/nidaq/nidaq.py +++ b/debye_bec/devices/nidaq/nidaq.py @@ -494,7 +494,7 @@ class Nidaq(PSIDeviceBase, NidaqControl): super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs) self.scan_info: ScanInfo self.timeout_wait_for_signal = 5 # put 5s firsts - self._timeout_wait_for_pv = 3 # 3s timeout for pv calls + self._timeout_wait_for_pv = 5 # 5s timeout for pv calls. editted due to timeout issues persisting self.valid_scan_names = [ "xas_simple_scan", "xas_simple_scan_with_xrd", @@ -693,7 +693,11 @@ class Nidaq(PSIDeviceBase, NidaqControl): # Stage call to IOC status = CompareStatus(self.state, NidaqState.STAGE) self.cancel_on_stop(status) - self.stage_call.set(1).wait(timeout=self._timeout_wait_for_pv) + # TODO 11.11.25/HS64 + # Switched from set to put in the hope to get rid of the rare event where nidaq is stopped at the start of a scan + # Problems consistently persisting, testing changing back to set, unconvinced this is the actual cause 14.11.25/AHC + # self.stage_call.set(1).wait(timeout=self._timeout_wait_for_pv) + self.stage_call.put(1) status.wait(timeout=self.timeout_wait_for_signal) if self.scan_info.msg.scan_name != "nidaq_continuous_scan": status = self.on_kickoff() From efd8842540ed413371b07dc2c2aa0320b113bd11 Mon Sep 17 00:00:00 2001 From: x01da Date: Tue, 2 Dec 2025 07:31:24 +0100 Subject: [PATCH 15/54] do not close pilatus curtain after measurements --- debye_bec/devices/pilatus_curtain.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debye_bec/devices/pilatus_curtain.py b/debye_bec/devices/pilatus_curtain.py index d129673..b607ae9 100644 --- a/debye_bec/devices/pilatus_curtain.py +++ b/debye_bec/devices/pilatus_curtain.py @@ -69,11 +69,11 @@ class PilatusCurtain(PSIDeviceBase): def on_unstage(self) -> DeviceStatus | None: """Called while unstaging the device.""" - return self.close() + # return self.close() def on_stop(self) -> DeviceStatus | None: """Called when the device is stopped.""" - return self.close() + # return self.close() def open(self) -> DeviceStatus | None: """Open the cover""" From 632d5542459de2b33c642e914b130e5445f1d167 Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 25 Mar 2026 09:48:25 +0100 Subject: [PATCH 16/54] add additional signals to nidaq --- debye_bec/devices/nidaq/nidaq.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/debye_bec/devices/nidaq/nidaq.py b/debye_bec/devices/nidaq/nidaq.py index 3cd2f58..ba0d0cf 100644 --- a/debye_bec/devices/nidaq/nidaq.py +++ b/debye_bec/devices/nidaq/nidaq.py @@ -431,6 +431,16 @@ class NidaqControl(Device): SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 17. STD" ) + cisum = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter sum" + ) + smpl_abs = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream sample absorption" + ) + ref_abs = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream reference absorption" + ) + xas_timestamp = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XAS timestamp") xrd_timestamp = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XRD timestamp") From c428bb5a87a0039c158b1f5681c6a460785d2f89 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 2 Apr 2026 14:22:29 +0200 Subject: [PATCH 17/54] Change of order of nidaq signals --- debye_bec/devices/nidaq/nidaq.py | 197 +++++++++++++++---------------- 1 file changed, 98 insertions(+), 99 deletions(-) diff --git a/debye_bec/devices/nidaq/nidaq.py b/debye_bec/devices/nidaq/nidaq.py index ba0d0cf..0685ba2 100644 --- a/debye_bec/devices/nidaq/nidaq.py +++ b/debye_bec/devices/nidaq/nidaq.py @@ -33,6 +33,104 @@ class NidaqControl(Device): """Nidaq control class with all PVs""" ### Readback PVs for EpicsEmitter ### + energy = Cpt(SetableSignal, value=0, kind=Kind.normal) + + smpl_abs = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream sample absorption" + ) + ref_abs = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream reference absorption" + ) + cisum = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter sum" + ) + + ai0_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 0, MEAN" + ) + ai1_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 1, MEAN" + ) + ai2_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 2, MEAN" + ) + ai3_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 3, MEAN" + ) + ai4_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 4, MEAN" + ) + ai5_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 5, MEAN" + ) + ai6_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 6, MEAN" + ) + ai7_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 7, MEAN" + ) + + di0_max = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream digital input 0, MAX") + di1_max = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream digital input 1, MAX") + di2_max = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream digital input 2, MAX") + di3_max = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream digital input 3, MAX") + di4_max = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream digital input 4, MAX") + + ci0_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 0, MEAN" + ) + ci1_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 1, MEAN" + ) + ci2_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 2, MEAN" + ) + ci3_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 3, MEAN" + ) + ci4_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 4, MEAN" + ) + ci5_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 5, MEAN" + ) + ci6_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 6, MEAN" + ) + ci7_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 7, MEAN" + ) + ci8_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 8, MEAN" + ) + ci9_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 9, MEAN" + ) + ci10_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 10, MEAN" + ) + ci11_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 11, MEAN" + ) + ci12_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 12, MEAN" + ) + ci13_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 13, MEAN" + ) + ci14_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 14, MEAN" + ) + ci15_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 15, MEAN" + ) + ci16_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 16, MEAN" + ) + ci17_mean = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 17, MEAN" + ) + ai0 = Cpt( EpicsSignalRO, suffix="NIDAQ-AI0", @@ -270,32 +368,6 @@ class NidaqControl(Device): ) ### Readback for BEC emitter ### - - ai0_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 0, MEAN" - ) - ai1_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 1, MEAN" - ) - ai2_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 2, MEAN" - ) - ai3_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 3, MEAN" - ) - ai4_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 4, MEAN" - ) - ai5_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 5, MEAN" - ) - ai6_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 6, MEAN" - ) - ai7_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 7, MEAN" - ) - ai0_std_dev = Cpt( SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 0, STD" ) @@ -321,61 +393,6 @@ class NidaqControl(Device): SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream analog input 7, STD" ) - ci0_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 0, MEAN" - ) - ci1_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 1, MEAN" - ) - ci2_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 2, MEAN" - ) - ci3_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 3, MEAN" - ) - ci4_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 4, MEAN" - ) - ci5_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 5, MEAN" - ) - ci6_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 6, MEAN" - ) - ci7_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 7, MEAN" - ) - ci8_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 8, MEAN" - ) - ci9_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 9, MEAN" - ) - ci10_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 10, MEAN" - ) - ci11_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 11, MEAN" - ) - ci12_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 12, MEAN" - ) - ci13_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 13, MEAN" - ) - ci14_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 14, MEAN" - ) - ci15_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 15, MEAN" - ) - ci16_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 16, MEAN" - ) - ci17_mean = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 17, MEAN" - ) - ci0_std_dev = Cpt( SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 0. STD" ) @@ -431,32 +448,14 @@ class NidaqControl(Device): SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter input 17. STD" ) - cisum = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream counter sum" - ) - smpl_abs = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream sample absorption" - ) - ref_abs = Cpt( - SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream reference absorption" - ) - xas_timestamp = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XAS timestamp") - xrd_timestamp = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XRD timestamp") xrd_angle = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XRD angle") xrd_energy = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XRD energy") xrd_ai0_mean = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XRD ai0 mean") xrd_ai0_std_dev = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream XRD ai0 std dev") - di0_max = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream digital input 0, MAX") - di1_max = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream digital input 1, MAX") - di2_max = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream digital input 2, MAX") - di3_max = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream digital input 3, MAX") - di4_max = Cpt(SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream digital input 4, MAX") - enc = Cpt(SetableSignal, value=0, kind=Kind.normal) - energy = Cpt(SetableSignal, value=0, kind=Kind.normal) rle = Cpt(SetableSignal, value=0, kind=Kind.normal) ### Control PVs ### From 4103b3153af7dcadd220bf35612b4e7475e52e6b Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 27 Apr 2026 15:20:02 +0200 Subject: [PATCH 18/54] feat: Added frontend absorber --- debye_bec/device_configs/x01da_frontend.yaml | 14 ++++ debye_bec/devices/absorber.py | 71 ++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 debye_bec/devices/absorber.py diff --git a/debye_bec/device_configs/x01da_frontend.yaml b/debye_bec/device_configs/x01da_frontend.yaml index bc097a3..b91a259 100644 --- a/debye_bec/device_configs/x01da_frontend.yaml +++ b/debye_bec/device_configs/x01da_frontend.yaml @@ -1,4 +1,18 @@ +################################### +## Frontend Absorber ## +################################### + +abs: + readoutPriority: baseline + description: Frontend Absorber + deviceClass: debye_bec.devices.absorber.Absorber + deviceConfig: + prefix: "X01DA-FE-ABS1:" + onFailure: retry + enabled: true + softwareTrigger: false + ################################### ## Frontend Slits ## ################################### diff --git a/debye_bec/devices/absorber.py b/debye_bec/devices/absorber.py new file mode 100644 index 0000000..1755a44 --- /dev/null +++ b/debye_bec/devices/absorber.py @@ -0,0 +1,71 @@ +"""Frontend Absorber""" + +from __future__ import annotations + +import enum +from typing import TYPE_CHECKING + +from ophyd import Component as Cpt +from ophyd import EpicsSignal, EpicsSignalRO +from ophyd_devices import CompareStatus, DeviceStatus +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase + +if TYPE_CHECKING: + from bec_lib.devicemanager import ScanInfo + +class AbsorberError(Exception): + """Absorber specific exception""" + +class STATUS(int, enum.Enum): + """Absorber States""" + + MOVING_CLOSE = 0 + OPEN = 1 + MOVING_OPEN = 2 + CLOSED = 3 + NOT_ENABLED = 4 + TIMEOUT_CLOSE = 5 + TIMEOUT_OPEN = 6 + CLOSE_LS_LOST = 7 + OPEN_LS_LOST = 8 + CLOSE_LS_NOT_FREE = 9 + OPEN_LS_NOT_FREE = 10 + ERROR_LS = 11 + TO_CONNECT = 12 + MAN_OPEN = 13 + UNDEFINED = 14 + +class Absorber(PSIDeviceBase): + """Class for the Frontend Absorber""" + + USER_ACCESS = ["open", "close"] + + request = Cpt(EpicsSignal, suffix="REQUEST", kind="config", doc="Open/Close Absorber") + status = Cpt(EpicsSignalRO, suffix="STATUS", kind="config", doc="Absorber Status") + + def __init__(self, *, name: str, prefix: str = "", scan_info: ScanInfo | None = None, **kwargs): + super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs) + + self.timeout_for_move = 10 + # Wait for connection on all components, ensure IOC is connected + self.wait_for_connection(all_signals=True, timeout=5) + + def open(self) -> DeviceStatus | None: + """Open the Absorber""" + if self.status.get() == STATUS.CLOSED: + self.request.put(1) + status_open = CompareStatus(self.status, STATUS.OPEN, timeout=self.timeout_for_move) + status = status_open + return status + else: + return None + + def close(self) -> DeviceStatus | None: + """Close the Absorber""" + if self.status.get() == STATUS.OPEN: + self.request.put(1) + status_close = CompareStatus(self.status, STATUS.CLOSED, timeout=self.timeout_for_move) + status = status_close + return status + else: + return None From 6a2d813506d06b00b7163a8f7b1939c15472f913 Mon Sep 17 00:00:00 2001 From: x01da Date: Tue, 28 Apr 2026 10:10:13 +0200 Subject: [PATCH 19/54] Corrected ot_rotx name --- debye_bec/device_configs/x01da_experimental_hutch.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debye_bec/device_configs/x01da_experimental_hutch.yaml b/debye_bec/device_configs/x01da_experimental_hutch.yaml index 08753f2..720d9f1 100644 --- a/debye_bec/device_configs/x01da_experimental_hutch.yaml +++ b/debye_bec/device_configs/x01da_experimental_hutch.yaml @@ -52,7 +52,7 @@ ot_try: enabled: true softwareTrigger: false -ot_pitch: +ot_rotx: readoutPriority: baseline description: Optical Table Pitch deviceClass: ophyd.EpicsMotor From adf3a8ab118389c4ca8c37389df9f01ce83adbc5 Mon Sep 17 00:00:00 2001 From: x01da Date: Tue, 28 Apr 2026 11:21:47 +0200 Subject: [PATCH 20/54] Renaming of offset signals --- .../devices/mo1_bragg/mo1_bragg_devices.py | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py b/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py index 6a4fe1a..ac5d451 100644 --- a/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py +++ b/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py @@ -76,8 +76,10 @@ class Mo1BraggEncoder(Device): class Mo1BraggCrystal(Device): """Mo1 Bragg PVs to set the crystal parameters""" - offset_si111 = Cpt(EpicsSignalWithRBV, suffix="offset_si111", kind="config") - offset_si311 = Cpt(EpicsSignalWithRBV, suffix="offset_si311", kind="config") + bragg_off_si111 = Cpt(EpicsSignalWithRBV, suffix="bragg_off_si111", kind="config") + bragg_off_si311 = Cpt(EpicsSignalWithRBV, suffix="bragg_off_si311", kind="config") + phi_off_si111 = Cpt(EpicsSignalWithRBV, suffix="phi_off_si111", kind="config") + phi_off_si311 = Cpt(EpicsSignalWithRBV, suffix="phi_off_si311", kind="config") xtal_enum = Cpt(EpicsSignalWithRBV, suffix="xtal_ENUM", kind="config") d_spacing_si111 = Cpt(EpicsSignalWithRBV, suffix="d_spacing_si111", kind="config") d_spacing_si311 = Cpt(EpicsSignalWithRBV, suffix="d_spacing_si311", kind="config") @@ -85,13 +87,15 @@ class Mo1BraggCrystal(Device): current_d_spacing = Cpt( EpicsSignalRO, suffix="current_d_spacing_RBV", kind="normal", auto_monitor=True ) - current_offset = Cpt( - EpicsSignalRO, suffix="current_offset_RBV", kind="normal", auto_monitor=True + current_bragg_off = Cpt( + EpicsSignalRO, suffix="current_bragg_off_RBV", kind="normal", auto_monitor=True + ) + current_phi_off = Cpt( + EpicsSignalRO, suffix="current_phi_off_RBV", kind="normal", auto_monitor=True ) current_xtal = Cpt( EpicsSignalRO, suffix="current_xtal_ENUM_RBV", kind="normal", auto_monitor=True ) - current_xtal_string = Cpt( EpicsSignalRO, suffix="current_xtal_ENUM_RBV", kind="normal", auto_monitor=True, string=True ) @@ -392,8 +396,8 @@ class Mo1BraggPositioner(Device, PositionerBase): def set_xtal( self, xtal_enum: Literal["111", "311"], - offset_si111: float = None, - offset_si311: float = None, + bragg_off_si111: float = None, + bragg_off_si311: float = None, d_spacing_si111: float = None, d_spacing_si311: float = None, ) -> None: @@ -401,15 +405,15 @@ class Mo1BraggPositioner(Device, PositionerBase): Args: xtal_enum (Literal["111", "311"]) : Enum to set the crystal orientation - offset_si111 (float) : Offset for the 111 crystal - offset_si311 (float) : Offset for the 311 crystal + bragg_off_si111 (float) : Offset for the 111 crystal + bragg_off_si311 (float) : Offset for the 311 crystal d_spacing_si111 (float) : d-spacing for the 111 crystal d_spacing_si311 (float) : d-spacing for the 311 crystal """ - if offset_si111 is not None: - self.crystal.offset_si111.put(offset_si111) - if offset_si311 is not None: - self.crystal.offset_si311.put(offset_si311) + if bragg_off_si111 is not None: + self.crystal.bragg_off_si111.put(bragg_off_si111) + if bragg_off_si311 is not None: + self.crystal.bragg_off_si311.put(bragg_off_si311) if d_spacing_si111 is not None: self.crystal.d_spacing_si111.put(d_spacing_si111) if d_spacing_si311 is not None: From 204e2827eb72d6d85aa5b5d206356f8f428131b7 Mon Sep 17 00:00:00 2001 From: x01da Date: Tue, 28 Apr 2026 15:14:48 +0200 Subject: [PATCH 21/54] Adding signals of additional nidaq signals --- debye_bec/devices/nidaq/nidaq.py | 38 +++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/debye_bec/devices/nidaq/nidaq.py b/debye_bec/devices/nidaq/nidaq.py index 0685ba2..35cb6f6 100644 --- a/debye_bec/devices/nidaq/nidaq.py +++ b/debye_bec/devices/nidaq/nidaq.py @@ -460,8 +460,8 @@ class NidaqControl(Device): ### Control PVs ### - enable_compression = Cpt(EpicsSignal, suffix="NIDAQ-EnableRLE", kind=Kind.config) - enable_dead_time_correction = Cpt(EpicsSignal, suffix="NIDAQ-EnableDTC", kind=Kind.config) + enable_compression = Cpt(EpicsSignal, suffix="NIDAQ-EnableRLE", kind=Kind.config, auto_monitor=True) + enable_dead_time_correction = Cpt(EpicsSignal, suffix="NIDAQ-EnableDTC", kind=Kind.config, auto_monitor=True) kickoff_call = Cpt(EpicsSignal, suffix="NIDAQ-Kickoff", kind=Kind.config) stage_call = Cpt(EpicsSignal, suffix="NIDAQ-Stage", kind=Kind.config) state = Cpt(EpicsSignal, suffix="NIDAQ-FSMState", kind=Kind.config, auto_monitor=True) @@ -469,21 +469,37 @@ class NidaqControl(Device): compression_ratio = Cpt(EpicsSignalRO, suffix="NIDAQ-CompressionRatio", kind=Kind.config) scan_type = Cpt(EpicsSignal, suffix="NIDAQ-ScanType", kind=Kind.config) scan_type_string = Cpt(EpicsSignal, suffix="NIDAQ-ScanType", kind=Kind.config, string=True) - sampling_rate = Cpt(EpicsSignal, suffix="NIDAQ-SamplingRateRequested", kind=Kind.config) - sampling_rate_string = Cpt(EpicsSignal, suffix="NIDAQ-SamplingRateRequested", kind=Kind.config, string=True) + sampling_rate = Cpt(EpicsSignal, suffix="NIDAQ-SamplingRateRequested", kind=Kind.config, auto_monitor=True) + sampling_rate_string = Cpt(EpicsSignal, suffix="NIDAQ-SamplingRateRequested", kind=Kind.config, string=True, auto_monitor=True) scan_duration = Cpt(EpicsSignal, suffix="NIDAQ-SamplingDuration", kind=Kind.config) - readout_range = Cpt(EpicsSignal, suffix="NIDAQ-ReadoutRange", kind=Kind.config) - readout_range_string = Cpt(EpicsSignal, suffix="NIDAQ-ReadoutRange", kind=Kind.config, string=True) - encoder_factor = Cpt(EpicsSignal, suffix="NIDAQ-EncoderFactor", kind=Kind.config) - encoder_factor_string = Cpt(EpicsSignal, suffix="NIDAQ-EncoderFactor", kind=Kind.config, string=True) + readout_range = Cpt(EpicsSignal, suffix="NIDAQ-ReadoutRange", kind=Kind.config, auto_monitor=True) + readout_range_string = Cpt(EpicsSignal, suffix="NIDAQ-ReadoutRange", kind=Kind.config, string=True, auto_monitor=True) + encoder_factor = Cpt(EpicsSignal, suffix="NIDAQ-EncoderFactor", kind=Kind.config, auto_monitor=True) + encoder_factor_string = Cpt(EpicsSignal, suffix="NIDAQ-EncoderFactor", kind=Kind.config, string=True, auto_monitor=True) stop_call = Cpt(EpicsSignal, suffix="NIDAQ-Stop", kind=Kind.config) power = Cpt(EpicsSignal, suffix="NIDAQ-Power", kind=Kind.config) heartbeat = Cpt(EpicsSignal, suffix="NIDAQ-Heartbeat", kind=Kind.config, auto_monitor=True) time_left = Cpt(EpicsSignalRO, suffix="NIDAQ-TimeLeft", kind=Kind.config, auto_monitor=True) - ai_chans = Cpt(EpicsSignal, suffix="NIDAQ-AIChans", kind=Kind.config) - ci_chans = Cpt(EpicsSignal, suffix="NIDAQ-CIChans", kind=Kind.config) - di_chans = Cpt(EpicsSignal, suffix="NIDAQ-DIChans", kind=Kind.config) + ai_chans = Cpt(EpicsSignal, suffix="NIDAQ-AIChans", kind=Kind.config, auto_monitor=True) + ci_chans = Cpt(EpicsSignal, suffix="NIDAQ-CIChans", kind=Kind.config, auto_monitor=True) + di_chans = Cpt(EpicsSignal, suffix="NIDAQ-DIChans", kind=Kind.config, auto_monitor=True) + add_chans = Cpt(EpicsSignal, suffix="NIDAQ-AddChans", kind=Kind.config, auto_monitor=True) + + smpl_abs_ln = Cpt(EpicsSignal, suffix="NIDAQ-smpl_abs_ln", kind=Kind.config, auto_monitor=True) + ref_abs_ln = Cpt(EpicsSignal, suffix="NIDAQ-ref_abs_ln", kind=Kind.config, auto_monitor=True) + + smpl_abs_no = Cpt(EpicsSignal, suffix="NIDAQ-smpl_abs_no", kind=Kind.config, auto_monitor=True) + smpl_abs_no_string = Cpt(EpicsSignal, suffix="NIDAQ-smpl_abs_no", kind=Kind.config, string=True, auto_monitor=True) + + smpl_abs_de = Cpt(EpicsSignal, suffix="NIDAQ-smpl_abs_de", kind=Kind.config, auto_monitor=True) + smpl_abs_de_string = Cpt(EpicsSignal, suffix="NIDAQ-smpl_abs_de", kind=Kind.config, string=True, auto_monitor=True) + + ref_abs_no = Cpt(EpicsSignal, suffix="NIDAQ-ref_abs_no", kind=Kind.config, auto_monitor=True) + ref_abs_no_string = Cpt(EpicsSignal, suffix="NIDAQ-ref_abs_no", kind=Kind.config, string=True, auto_monitor=True) + + ref_abs_de = Cpt(EpicsSignal, suffix="NIDAQ-ref_abs_de", kind=Kind.config, auto_monitor=True) + ref_abs_de_string = Cpt(EpicsSignal, suffix="NIDAQ-ref_abs_de", kind=Kind.config, string=True, auto_monitor=True) class Nidaq(PSIDeviceBase, NidaqControl): From 6e149a6a734514084abffe5f155045404f8a371d Mon Sep 17 00:00:00 2001 From: x01da Date: Tue, 28 Apr 2026 15:16:03 +0200 Subject: [PATCH 22/54] Adding string representation of status --- debye_bec/devices/absorber.py | 1 + 1 file changed, 1 insertion(+) diff --git a/debye_bec/devices/absorber.py b/debye_bec/devices/absorber.py index 1755a44..e359357 100644 --- a/debye_bec/devices/absorber.py +++ b/debye_bec/devices/absorber.py @@ -42,6 +42,7 @@ class Absorber(PSIDeviceBase): request = Cpt(EpicsSignal, suffix="REQUEST", kind="config", doc="Open/Close Absorber") status = Cpt(EpicsSignalRO, suffix="STATUS", kind="config", doc="Absorber Status") + status_string = Cpt(EpicsSignalRO, suffix="STATUS", kind="config", string=True, doc="Absorber Status") def __init__(self, *, name: str, prefix: str = "", scan_info: ScanInfo | None = None, **kwargs): super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs) From 313265839633809f091641725c0acf71cee0f814 Mon Sep 17 00:00:00 2001 From: x01da Date: Tue, 28 Apr 2026 15:16:40 +0200 Subject: [PATCH 23/54] Signal name change (consistency) --- debye_bec/device_configs/x01da_optics.yaml | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/debye_bec/device_configs/x01da_optics.yaml b/debye_bec/device_configs/x01da_optics.yaml index 6d77341..ce1a27e 100644 --- a/debye_bec/device_configs/x01da_optics.yaml +++ b/debye_bec/device_configs/x01da_optics.yaml @@ -3,7 +3,7 @@ ## Monochromator ## ################################### -mo_try: +mo1_try: readoutPriority: baseline description: Monochromator Y Translation deviceClass: ophyd.EpicsMotor @@ -13,7 +13,7 @@ mo_try: enabled: true softwareTrigger: false -mo_trx: +mo1_trx: readoutPriority: baseline description: Monochromator X Translation deviceClass: ophyd.EpicsMotor @@ -23,7 +23,7 @@ mo_trx: enabled: true softwareTrigger: false -mo_roty: +mo1_roty: readoutPriority: baseline description: Monochromator Yaw deviceClass: ophyd.EpicsMotor @@ -167,6 +167,7 @@ fm_trxu: onFailure: retry enabled: true softwareTrigger: false + fm_trxd: readoutPriority: baseline description: Focusing Mirror X-translation downstream @@ -176,6 +177,7 @@ fm_trxd: onFailure: retry enabled: true softwareTrigger: false + fm_tryd: readoutPriority: baseline description: Focusing Mirror Y-translation downstream @@ -185,6 +187,7 @@ fm_tryd: onFailure: retry enabled: true softwareTrigger: false + fm_tryur: readoutPriority: baseline description: Focusing Mirror Y-translation upstream ring @@ -194,6 +197,7 @@ fm_tryur: onFailure: retry enabled: true softwareTrigger: false + fm_tryuw: readoutPriority: baseline description: Focusing Mirror Y-translation upstream wall @@ -203,6 +207,7 @@ fm_tryuw: onFailure: retry enabled: true softwareTrigger: false + fm_bnd: readoutPriority: baseline description: Focusing Mirror bender @@ -213,6 +218,17 @@ fm_bnd: enabled: true softwareTrigger: false +fm_bnd_radius: + readoutPriority: baseline + description: Focusing Mirror Bending Radius + deviceClass: ophyd.EpicsSignalRO + deviceConfig: + read_pv: X01DA-CPCL-FM:BNDFORCE + onFailure: retry + readOnly: true + enabled: true + softwareTrigger: false + fm_rotx: readoutPriority: baseline description: Focusing Morror Pitch From 7fb68d67de3caeae79a739a07c8ea40f103f1964 Mon Sep 17 00:00:00 2001 From: x01da Date: Tue, 28 Apr 2026 15:16:59 +0200 Subject: [PATCH 24/54] Adding bender radius signal --- debye_bec/device_configs/x01da_frontend.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/debye_bec/device_configs/x01da_frontend.yaml b/debye_bec/device_configs/x01da_frontend.yaml index b91a259..9532428 100644 --- a/debye_bec/device_configs/x01da_frontend.yaml +++ b/debye_bec/device_configs/x01da_frontend.yaml @@ -161,6 +161,17 @@ cm_bnd: enabled: true softwareTrigger: false +cm_bnd_radius: + readoutPriority: baseline + description: Collimating Mirror Bending Radius + deviceClass: ophyd.EpicsSignalRO + deviceConfig: + read_pv: X01DA-CPCL-CM:BNDFORCE + onFailure: retry + readOnly: true + enabled: true + softwareTrigger: false + cm_rotx: readoutPriority: baseline description: Collimating Morror Pitch From f3fbdbf5f22c1d18e4602c4b6efdc68ede7a2c39 Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 29 Apr 2026 14:18:48 +0200 Subject: [PATCH 25/54] update of config and nexus structure --- .../device_configs/x01da_standard_config.yaml | 20 +- .../file_writer/debye_nexus_structure.py | 323 ++++++++++++------ 2 files changed, 237 insertions(+), 106 deletions(-) diff --git a/debye_bec/device_configs/x01da_standard_config.yaml b/debye_bec/device_configs/x01da_standard_config.yaml index 3490ae0..2015fef 100644 --- a/debye_bec/device_configs/x01da_standard_config.yaml +++ b/debye_bec/device_configs/x01da_standard_config.yaml @@ -25,7 +25,7 @@ frontend_config: ## Bragg Monochromator mo1_bragg: - readoutPriority: monitored + readoutPriority: baseline description: Positioner for the Monochromator deviceClass: debye_bec.devices.mo1_bragg.mo1_bragg.Mo1Bragg deviceConfig: @@ -33,15 +33,15 @@ mo1_bragg: onFailure: retry enabled: true softwareTrigger: false -mo1_bragg_angle: - readoutPriority: baseline - description: Positioner for the Monochromator - deviceClass: debye_bec.devices.mo1_bragg.mo1_bragg_angle.Mo1BraggAngle - deviceConfig: - prefix: "X01DA-OP-MO1:BRAGG:" - onFailure: retry - enabled: true - softwareTrigger: false +#mo1_bragg_angle: +# readoutPriority: baseline +# description: Positioner for the Monochromator +# deviceClass: debye_bec.devices.mo1_bragg.mo1_bragg_angle.Mo1BraggAngle +# deviceConfig: +# prefix: "X01DA-OP-MO1:BRAGG:" +# onFailure: retry +# enabled: true +# softwareTrigger: false ## Remaining optics hutch optics_config: diff --git a/debye_bec/file_writer/debye_nexus_structure.py b/debye_bec/file_writer/debye_nexus_structure.py index ebabcd7..33cf7b2 100644 --- a/debye_bec/file_writer/debye_nexus_structure.py +++ b/debye_bec/file_writer/debye_nexus_structure.py @@ -12,102 +12,6 @@ class DebyeNexusStructure(DefaultFormat): instrument = entry.create_group(name="instrument") instrument.attrs["NX_class"] = "NXinstrument" - ################### - ## mo1_bragg specific information - ################### - - # Logic if device exist - if "mo1_bragg" in self.device_manager.devices: - - monochromator = instrument.create_group(name="monochromator") - monochromator.attrs["NX_class"] = "NXmonochromator" - crystal = monochromator.create_group(name="crystal") - crystal.attrs["NX_class"] = "NXcrystal" - - # Create a dataset - chemical_formular = crystal.create_dataset(name="chemical_formular", data="Si") - chemical_formular.attrs["NX_class"] = "NX_CHAR" - - # Create a softlink - d_spacing = crystal.create_soft_link( - name="d_spacing", - target="/entry/collection/devices/mo1_bragg/mo1_bragg_crystal_current_d_spacing/value", - ) - d_spacing.attrs["NX_class"] = "NX_FLOAT" - - offset = crystal.create_soft_link( - name="offset", - target="/entry/collection/devices/mo1_bragg/mo1_bragg_crystal_current_offset/value", - ) - offset.attrs["NX_class"] = "NX_FLOAT" - - reflection = crystal.create_soft_link( - name="reflection", - target="/entry/collection/devices/mo1_bragg/mo1_bragg_crystal_current_xtal_string/value", - ) - reflection.attrs["NX_class"] = "NX_CHAR" - - ################## - ## cm mirror specific information - ################### - - collimating_mirror = instrument.create_group(name="collimating_mirror") - collimating_mirror.attrs["NX_class"] = "NXmirror" - - cm_substrate_material = collimating_mirror.create_dataset( - name="substrate_material", data="Si" - ) - cm_substrate_material.attrs["NX_class"] = "NX_CHAR" - - cm_bending_radius = collimating_mirror.create_soft_link( - name="sagittal radius", - target="/entry/collection/devices/cm_bnd_radius/cm_bnd_radius/value", - ) - cm_bending_radius.attrs["NX_class"] = "NX_FLOAT" - cm_bending_radius.attrs["units"] = "km" - - cm_incidence_angle = collimating_mirror.create_soft_link( - name="incidence angle", target="/entry/collection/devices/cm_rotx/cm_rotx/value" - ) - cm_incidence_angle.attrs["NX_class"] = "NX_FLOAT" - - cm_yaw_angle = collimating_mirror.create_soft_link( - name="incident angle", target="/entry/collection/devices/cm_roty/cm_roty/value" - ) - cm_yaw_angle.attrs["NX_class"] = "NX_FLOAT" - - ################## - ## fm mirror specific information - ################### - - focusing_mirror = instrument.create_group(name="focusing_mirror") - focusing_mirror.attrs["NX_class"] = "NXmirror" - - fm_substrate_material = focusing_mirror.create_dataset(name="substrate_material", data="Si") - fm_substrate_material.attrs["NX_class"] = "NX_CHAR" - - fm_bending_radius = focusing_mirror.create_soft_link( - name="sagittal radius", - target="/entry/collection/devices/fm_bnd_radius/fm_bnd_radius/value", - ) - fm_bending_radius.attrs["NX_class"] = "NX_FLOAT" - - fm_incidence_angle = focusing_mirror.create_soft_link( - name="incidence angle", - target="/entry/collection/devices/fm_incidence_angle/fm_incidence_angle/value", - ) - fm_incidence_angle.attrs["NX_class"] = "NX_FLOAT" - - fm_yaw_angle = focusing_mirror.create_soft_link( - name="yaw angle", target="/entry/collection/devices/fm_roty/fm_roty/value" - ) - fm_yaw_angle.attrs["NX_class"] = "NX_FLOAT" - - fm_roll_angle = focusing_mirror.create_soft_link( - name="roll angle", target="/entry/collection/devices/fm_rotz/fm_rotz/value" - ) - fm_roll_angle.attrs["NX_class"] = "NX_FLOAT" - ################## ## source specific information ################### @@ -123,3 +27,230 @@ class DebyeNexusStructure(DefaultFormat): probe = source.create_dataset(name="probe", data="X-ray") probe.attrs["NX_class"] = "NX_CHAR" + + if "curr" in self.device_manager.devices: + ring_current = source.create_soft_link( + name="ring_current", + target="/entry/collection/devices/curr/curr/value", + ) + ring_current.attrs["NX_class"] = "NX_FLOAT" + ring_current.attrs["units"] = "mA" + + ################### + ## mo1_bragg specific information + ################### + + ## Logic if device exist + if "mo1_bragg" in self.device_manager.devices: + + monochromator = instrument.create_group(name="monochromator") + monochromator.attrs["NX_class"] = "NXmonochromator" + crystal = monochromator.create_group(name="crystal") + crystal.attrs["NX_class"] = "NXcrystal" + + # Create a dataset + chemical_formular = crystal.create_dataset(name="chemical_formular", data="Si") + chemical_formular.attrs["NX_class"] = "NX_CHAR" + + reflection = crystal.create_soft_link( + name="reflection", + target="/entry/collection/devices/mo1_bragg/mo1_bragg_crystal_current_xtal_string/value", + ) + reflection.attrs["NX_class"] = "NX_CHAR" + + # Create a softlink + d_spacing = crystal.create_soft_link( + name="d_spacing", + target="/entry/collection/devices/mo1_bragg/mo1_bragg_crystal_current_d_spacing/value", + ) + d_spacing.attrs["NX_class"] = "NX_FLOAT" + d_spacing.attrs["units"] = "angstrom" + + bragg_offset = crystal.create_soft_link( + name="bragg_offset", + target="/entry/collection/devices/mo1_bragg/mo1_bragg_crystal_current_bragg_off/value", + ) + bragg_offset.attrs["NX_class"] = "NX_FLOAT" + bragg_offset.attrs["units"] = "degree" + + phi_offset = crystal.create_soft_link( + name="phi_offset", + target="/entry/collection/devices/mo1_bragg/mo1_bragg_crystal_current_phi_off/value", + ) + phi_offset.attrs["NX_class"] = "NX_FLOAT" + phi_offset.attrs["units"] = "degree" + + ## Logic if device exist + if "mo1_roty" in self.device_manager.devices: + + # Create a softlink + azimuthal_angle = crystal.create_soft_link( + name="azimuthal_angle", + target="/entry/collection/devices/mo1_roty/mo1_roty/value", + ) + azimuthal_angle.attrs["NX_class"] = "NX_FLOAT" + azimuthal_angle.attrs["units"] = "degree" + + + #TODO add phi offset, currently missing from mo1 device, unify device naming (mo vs mo1) + + ################### + ### cm mirror specific information + #################### + + collimating_mirror = instrument.create_group(name="collimating_mirror") + collimating_mirror.attrs["NX_class"] = "NXmirror" + + cm_substrate_material = collimating_mirror.create_dataset( + name="substrate_material", data="Si" + ) + cm_substrate_material.attrs["NX_class"] = "NX_CHAR" + + #previous error due to space in name field + + if "cm_bnd_radius" in self.device_manager.devices: + cm_bending_radius = collimating_mirror.create_soft_link( + name="sagittal_radius", + target="/entry/collection/devices/cm_bnd_radius/cm_bnd_radius/value", + ) + cm_bending_radius.attrs["NX_class"] = "NX_FLOAT" + cm_bending_radius.attrs["units"] = "km" + + if "cm_rotx" in self.device_manager.devices: + cm_incidence_angle = collimating_mirror.create_soft_link( + name="incidence angle", target="/entry/collection/devices/cm_rotx/cm_rotx/value" + ) + cm_incidence_angle.attrs["NX_class"] = "NX_FLOAT" + cm_incidence_angle.attrs["units"] = "mrad" + + if "cm_roty" in self.device_manager.devices: + cm_yaw_angle = collimating_mirror.create_soft_link( + name="yaw angle", target="/entry/collection/devices/cm_roty/cm_roty/value" + ) + cm_yaw_angle.attrs["NX_class"] = "NX_FLOAT" + cm_yaw_angle.attrs["units"] = "mrad" + + if "cm_rotz" in self.device_manager.devices: + cm_roll_angle = collimating_mirror.create_soft_link( + name="roll angle", target="/entry/collection/devices/cm_rotz/cm_rotz/value" + ) + cm_roll_angle.attrs["NX_class"] = "NX_FLOAT" + cm_roll_angle.attrs["units"] = "mrad" + + + ################### + ### fm mirror specific information + #################### + + focusing_mirror = instrument.create_group(name="focusing_mirror") + focusing_mirror.attrs["NX_class"] = "NXmirror" + + fm_substrate_material = focusing_mirror.create_dataset( + name="substrate_material", data="Si" + ) + fm_substrate_material.attrs["NX_class"] = "NX_CHAR" + + if "fm_bnd_radius" in self.device_manager.devices: + fm_bending_radius = focusing_mirror.create_soft_link( + name="sagittal_radius", + target="/entry/collection/devices/fm_bnd_radius/fm_bnd_radius/value", + ) + fm_bending_radius.attrs["NX_class"] = "NX_FLOAT" + fm_bending_radius.attrs["units"] = "km" + + if "fm_rotx" in self.device_manager.devices: + fm_incidence_angle = focusing_mirror.create_soft_link( + name="incidence angle", target="/entry/collection/devices/fm_rotx/fm_rotx/value" + ) + fm_incidence_angle.attrs["NX_class"] = "NX_FLOAT" + fm_incidence_angle.attrs["units"] = "mrad" + + if "fm_roty" in self.device_manager.devices: + fm_yaw_angle = focusing_mirror.create_soft_link( + name="yaw angle", target="/entry/collection/devices/fm_roty/fm_roty/value" + ) + fm_yaw_angle.attrs["NX_class"] = "NX_FLOAT" + fm_yaw_angle.attrs["units"] = "mrad" + + if "fm_rotz" in self.device_manager.devices: + fm_roll_angle = focusing_mirror.create_soft_link( + name="roll angle", target="/entry/collection/devices/fm_rotz/fm_rotz/value" + ) + fm_roll_angle.attrs["NX_class"] = "NX_FLOAT" + fm_roll_angle.attrs["units"] = "mrad" + + + ################### + ## nidaq specific information + ################### + + ## Logic if device exist + if "nidaq" in self.device_manager.devices: + + #ai_chans_bit = self.device_manager.devices.nidaq.ai_chans.read().get("nidaq_ai_chans").get("value") + + main_data = entry.create_group(name="data") + main_data.attrs["NX_class"] = "NXdata" + + ################## + ## energy, test whether the signal exists. how to check from config? + ################### + + energy = main_data.create_group(name="energy") + energy.attrs["NX_class"] = "NXdata" + energy.attrs["units"] = "eV" + + main_data.create_soft_link(name="energy", target="/entry/collection/readout_groups/async/nidaq/nidaq_energy/value") + + ################## + ## i0, test whether the signal exists. how to check from config? + ################### + + #if (int(ai_chans_bit) & (1<<0)) !=0: + i0 = main_data.create_group(name="i0") + i0.attrs["NX_class"] = "NXdata" + i0.attrs["units"] = "V" + + main_data.create_soft_link(name="i0", target="/entry/collection/readout_groups/async/nidaq/nidaq_ai0_mean/value") + + ################## + ## i1, test whether the signal exists. how to check from config? + ################### + + i1 = main_data.create_group(name="i1") + i1.attrs["NX_class"] = "NXdata" + i1.attrs["units"] = "V" + + main_data.create_soft_link(name="i1", target="/entry/collection/readout_groups/async/nidaq/nidaq_ai2_mean/value") + + ################## + ## i2, test whether the signal exists. how to check from config? + ################### + + i2 = main_data.create_group(name="i2") + i2.attrs["NX_class"] = "NXdata" + i2.attrs["units"] = "V" + + main_data.create_soft_link(name="i2", target="/entry/collection/readout_groups/async/nidaq/nidaq_ai4_mean/value") + + ################## + ## mu sample, test whether the signal exists. how to check from config? + ################### + + mu_sample = main_data.create_group(name="mu_sample") + mu_sample.attrs["NX_class"] = "NXdata" + + main_data.create_soft_link(name="mu_sample", target="/entry/collection/readout_groups/async/nidaq/nidaq_smpl_abs/value") + + ################## + ## mu reference, test whether the signal exists. how to check from config? + ################### + + mu_reference = main_data.create_group(name="mu_reference") + mu_reference.attrs["NX_class"] = "NXdata" + + main_data.create_soft_link(name="mu_reference", target="/entry/collection/readout_groups/async/nidaq/nidaq_ref_abs/value") + + + + From 588152871c9982155566276734756908da2a20dc Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 29 Apr 2026 14:21:46 +0200 Subject: [PATCH 26/54] wip: move components to label --- .../plugins/move_to_label.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 debye_bec/bec_ipython_client/plugins/move_to_label.py diff --git a/debye_bec/bec_ipython_client/plugins/move_to_label.py b/debye_bec/bec_ipython_client/plugins/move_to_label.py new file mode 100644 index 0000000..dd14970 --- /dev/null +++ b/debye_bec/bec_ipython_client/plugins/move_to_label.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import builtins +from typing import TYPE_CHECKING + +from bec_lib import bec_logger +from debye_bec.devices.absorber import STATUS as ABS_STATUS + +logger = bec_logger.logger +# import builtins to avoid linter errors +dev = builtins.__dict__.get("dev") + +class MoveToLabelError(Exception): + """Exception for the MoveToLabel function""" + +def move_to_label(): + """ + Function to move several motors to a specific position defined in the label dict. + """ + + label = get_device_conditions(label="digitalTwin") + + # Get absorber status and close if open + logger.info("Check Frontend Absorber Status") + abs_was_open = dev.abs.status.get() == ABS_STATUS.OPEN + if abs_was_open: + logger.info(" Close Frontend Absorber") + status = dev.abs.close() + status.wait() + + # Move Frontend Slits + logger.info("Move Frontend Slits into position") + devices = ["sldi_centerx", "sldi_centery", "sldi_gapx", "sldi_gapy"] + matches = {key: label[key] for key in devices if key in label} + statuses = [] + for device in matches.values(): + statuses.append(device['device'].move(device['value'])) + for status in statuses: + status.wait(timeout=30) + + # Move Collimating mirror + logger.info("Move Collimating Mirror into position") + if "cm_rotx" in label: # pitch + logger.info(" Move pitch into position") + surveyed_movement( + axis=label['cm_rotx'], + surveyed_axes= [ + {'device': dev.cm_rotz, 'abs_tol': 0.1}, + ] + ) + + # Restore absorber position + logger.info("Restore Frontend Absorber Status") + if abs_was_open: + status = dev.abs.open() + status.wait() + + +def surveyed_movement(axis, surveyed_axes): + """ + Moves an axis while surverying a set of axes. + + Args: + axis (DeviceCondition): Device condition + surveyed_axes (list): List of dicts (same format as DeviceCondition) + + Raises: + If during movement of axis, one of the surveyed axes moves out of tolerance. + """ + + for surv_ax in surveyed_axes: + surv_ax['old_value'] = surv_ax['device'].read() + status = axis['device'].move(axis['value']) + while status.status == 'RUNNING': + for surv_ax in surveyed_axes: + if abs(surv_ax['device'].read() - surv_ax['old_value']) > surv_ax['abs_tol']: + axis['device'].stop() + raise MoveToLabelError( + f"During movement of {axis['device'].name}, {surv_ax['device'].name} " + + f"started to move unexpectedly (old pos: {surv_ax['old_value']}, " + + f"current pos: {surv_ax['device'].read()})" + ) From 339adab06cb84e094b73514af1c128424c5b8435 Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 29 Apr 2026 14:22:10 +0200 Subject: [PATCH 27/54] wip: digital twin widget --- debye_bec/bec_widgets/widgets/client.py | 31 ++ .../widgets/digital_twin/__init__.py | 0 .../digital_twin/calculate_positions.py | 221 +++++++++++ .../widgets/digital_twin/digital_twin.py | 365 ++++++++++++++++++ .../digital_twin/digital_twin.pyproject | 1 + .../digital_twin/digital_twin_plugin.py | 57 +++ .../digital_twin/register_digital_twin.py | 15 + .../widgets/digital_twin/x01da_parameters.py | 289 ++++++++++++++ debye_bec/bec_widgets/widgets/qt_widgets.py | 273 +++++++++++++ 9 files changed, 1252 insertions(+) create mode 100644 debye_bec/bec_widgets/widgets/client.py create mode 100644 debye_bec/bec_widgets/widgets/digital_twin/__init__.py create mode 100644 debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py create mode 100644 debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py create mode 100644 debye_bec/bec_widgets/widgets/digital_twin/digital_twin.pyproject create mode 100644 debye_bec/bec_widgets/widgets/digital_twin/digital_twin_plugin.py create mode 100644 debye_bec/bec_widgets/widgets/digital_twin/register_digital_twin.py create mode 100644 debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py create mode 100644 debye_bec/bec_widgets/widgets/qt_widgets.py diff --git a/debye_bec/bec_widgets/widgets/client.py b/debye_bec/bec_widgets/widgets/client.py new file mode 100644 index 0000000..91294ab --- /dev/null +++ b/debye_bec/bec_widgets/widgets/client.py @@ -0,0 +1,31 @@ +# This file was automatically generated by generate_cli.py +# type: ignore + +from __future__ import annotations + +from bec_lib.logger import bec_logger + +from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout + +logger = bec_logger.logger + +# pylint: skip-file + + +_Widgets = {} + + +class DigitalTwin(RPCBase): + """A simple BEC widget with:""" + + @rpc_call + def set_a(self, value: float): + """ + Set input A remotely from the BEC CLI. + """ + + @rpc_call + def set_b(self, value: float): + """ + Set input B remotely from the BEC CLI. + """ diff --git a/debye_bec/bec_widgets/widgets/digital_twin/__init__.py b/debye_bec/bec_widgets/widgets/digital_twin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py b/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py new file mode 100644 index 0000000..f15b289 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py @@ -0,0 +1,221 @@ +import os +import numpy as np +from bec_lib import bec_logger + +os.environ["USE_XRT"] = "False" +import debye_bec.bec_ipython_client.plugins.digital_twin.x01da_parameters as bl + +logger = bec_logger.logger + +def calculate_positions(cfg): + + pos = {} + + ## FE slits + trxr = -np.arctan(cfg['h_acc'])*bl.feSlits.center1[1] + trxw = (np.arctan(cfg['h_acc'])*bl.feSlits.center1[1])/bl.feSlits.center1[1]*bl.feSlits.center2[1] + tryb = -np.arctan(cfg['v_acc'])*bl.feSlits.center1[1] + tryt = (np.arctan(cfg['v_acc'])*bl.feSlits.center1[1])/bl.feSlits.center1[1]*bl.feSlits.center2[1] + + trxw_proj = trxw/bl.feSlits.center2[1]*bl.feSlits.center1[1] + tryt_proj = tryt/bl.feSlits.center2[1]*bl.feSlits.center1[1] + + xcen = (trxr + trxw_proj) / 2 + ycen = (tryb + tryt_proj) / 2 + xgap = trxw_proj - trxr + ygap = tryt_proj - tryb + + pos['sldi_gapx'] = {'value': xgap} + pos['sldi_gapy'] = {'value': ygap} + + ## Collimating Mirror + obj_dist = bl.cm.center[1] # object distance + + # TRX + try: + index = bl.cm.surface.index(cfg['cm_stripe']) + except: + raise ValueError(f"Requested stripe {cfg['cm_stripe']} not found in parameters!") + cm_trx = -(bl.cm.limOptX[0][index] + bl.cm.limOptX[1][index]) / 2 + pos['cm_trx'] = {'value': cm_trx} + + # TRY + height = obj_dist * np.tan(cfg['v_acc'])**2 * 1 / np.tan(cfg['cm_pitch']) + pos['cm_try'] = {'value': height} + + # Pitch + pos['cm_rotx'] = {'value': -cfg["cm_pitch"]*1e3} # invert and convert to mrad (same as EGU of rotx axis) + + # Bending Radius + radius = 2. * obj_dist / np.sin(cfg['cm_pitch']) # Elements of modern X-ray Physics, page 108 ff. + pos['cm_bnd_radius'] = {'value': radius * 1e-6} # Convert to km + + ## Monochromator + # Bragg Angle + # TODO Should the bragg angle be corrected for the symmetric bragg case? + # See raytracing script or here: bragg = np.asin(rm.ch / (2.*cfg['dSpacing']*cfg['energyCCM'])) - aCrystal.get_dtheta_symmetric_Bragg(cfg['energyCCM']) + if cfg['mo_mode'] == 'Monochromatic': + # Add 2x CM pitch to the bragg angle + bragg = ((2 * cfg['cm_pitch']) + cfg['mo_bragg'][1]) / np.pi * 180 + elif cfg['mo_mode'] == 'Pinkbeam': + # Align xtal surfaces parallel to beam + bragg = (2 * cfg['cm_pitch']) / np.pi * 180 + else: + raise Exception('Monochromator mode not supported') + pos['mo1_bragg_angle'] = {'value': bragg} # Bragg angle in deg + + # TRY, Height + l = bl.mo1.xtalGap[0]/np.sin(cfg['mo_bragg'][1]) + yhor = l*np.cos(2.*(cfg['mo_bragg'][1]+cfg['cm_pitch'])) + yver = yhor*np.tan(2.*cfg['cm_pitch']) + + if cfg['mo_mode'] == 'Monochromatic': + beamOffsetCCM = l*np.sin(2.*(cfg['mo_bragg'][1]+cfg['cm_pitch']))-yver # Resultat ist korrekt! + elif cfg['mo_mode'] == 'Pinkbeam': + beamOffsetCCM = 0 + else: + raise Exception('Monochromator mode not supported') + + def csc(a): + return 1/np.sin(a) + + def cot(a): + return 1/np.tan(a) + + # calculate height of center of first crystal surface + f = bl.mo1.rotOffset # rotation offset, mm + logger.info(f'f = {f}') + d = bl.mo1.heightOffset # xtal height offset, mm + logger.info(f'd = {d}') + c = d*csc(cfg['mo_bragg'][1])-f*cot(cfg['mo_bragg'][1]) + logger.info(f'c = {c}') + + # Calculate height of center of rotation + b = np.sqrt(d**2*csc(cfg['mo_bragg'][1])**2-2*d*f*cot(cfg['mo_bragg'][1])*csc(cfg['mo_bragg'][1])+f**2*cot(cfg['mo_bragg'][1])**2+f**2) + logger.info(f'b = {b}') + h = np.cos(np.pi/2-np.arctan(f/c)-cfg['mo_bragg'][1]-2*cfg['cm_pitch'])*b + logger.info(f'h = {h}') + h2 = ((bl.mo1.center[1] - bl.cm.center[1])-np.sqrt(b**2-h**2))*np.tan(2*cfg['cm_pitch']) + logger.info(f'mo1 = {bl.mo1.center[1]}') + logger.info(f'cm = {bl.cm.center[1]}') + logger.info(f'pitch = {cfg["cm_pitch"]}') + logger.info(f'h2 = {h2}') + #TODO Mono height not exactly the same as in raytracing + heightCCM1real = h + h2 # per design, the height should not change if the pitch of the CM is not changed! + # heightCCM1real = heightCCM1real - 30 # Zero position of stage is at 1430 mm from ground. + if cfg['mo_mode'] == 'Monochromatic': + pass + elif cfg['mo_mode'] == 'Pinkbeam': + heightCCM1real = heightCCM1real - 13 # Move down to let beam pass between both crystal without touching copper cooler + else: + raise Exception('Monochromator mode not supported') + pos['mo1_try'] = {'value': heightCCM1real} + + # TRX, Crystal selection + try: + xtal = cfg['mo_xtal'].translate(str.maketrans('', '', '()')) # Remove brackets from xtal name to conform with parameters + index = bl.mo1.xtal.index(xtal) + except: + raise ValueError(f"Requested xtal {xtal} not found in parameters!") + pos['mo1_trx'] = {'value': bl.mo1.xtalOffsetX[index]} + + + #TODO move to mono, calc for beam Z-movement between crystal surfaces + diag = bl.mo1.xtalGap[0] / np.sin(bragg) # Calculations for Mono + dz = diag * np.cos(2 * (cfg['cm_pitch'] + bragg)) + + ## Slits 1 + d = bl.opSlits1.center[1] - bl.cm.center[1] - dz + sl1_beam_height = d * np.tan(2 * cfg['cm_pitch']) + beamOffsetCCM + pos['sl1_centery'] = {'value': sl1_beam_height} + + ## Beam Monitor 1 + d = bl.opBM1.center[1] - bl.cm.center[1] - dz + logger.info(f'distance: {d}') + logger.info(f'cm pitch: {cfg["cm_pitch"]}') + logger.info(f'mono offset: {beamOffsetCCM}') + bm1_beam_height = d * np.tan(2 * cfg['cm_pitch']) + beamOffsetCCM + pos['bm1_try'] = {'value': bm1_beam_height} + + ## Focusing Mirror + p = bl.fm.center[1] + q = cfg['smpl'] - bl.fm.center[1] + f = (p*q)/(p+q) # focal length + + # Bender radius + radius = 2 * q / np.sin(cfg['fm_pitch']) # ideal bending radius + pos['fm_bnd_radius'] = {'value': radius * 1e-6} # Convert to km + + # Pitch + d = bl.fm.center[1] - bl.cm.center[1] - dz + fm_pitch = 2 * cfg['cm_pitch'] - cfg['fm_pitch'] # calculate pitch in absolute values (according to horizontal plane) + pos['fm_rotx'] = {'value': -fm_pitch * 1e3} # invert and convert to mrad (same as EGU of rotx axis) + + if cfg['fm_stripe'] in ('Rh (toroid)', 'Pt (toroid)'): + + # TRY + if cfg['fm_stripe'] in 'Rh (toroid)': + r = bl.fm.r[0] + h_cyl = bl.fm.hToroid[0] + else: # PT toroid + r = bl.fm.r[1] + h_cyl = bl.fm.hToroid[1] + widthBeam = 2 * bl.fm.center[1] * np.tan(cfg['h_acc'] * 1e-3) + alpha = np.arccos(1 - widthBeam**2 / (2 * r**2)) + h = r - (r * np.cos(alpha / 2)) + fm_beam_height = (d * np.tan(2 * cfg['cm_pitch']) + beamOffsetCCM) * cfg['fm_gain_height'] + fm_height = (d * np.tan(2 * cfg['cm_pitch']) + beamOffsetCCM - h_cyl + h / 2) * cfg['fm_gain_height'] + pos['fm_try'] = {'value': fm_height} + + # TRX + if cfg['fm_stripe'] in 'Rh (toroid)': + x_cyl = - bl.fm.xToroid[0] + else: + x_cyl = - bl.fm.xToroid[1] + pos['fm_trx'] = {'value': x_cyl} + + elif cfg['fm_stripe'] in ('Rh (flat)', 'Pt (flat)'): + + # TRY + fm_height = (d * np.tan(2 * cfg['cm_pitch']) + beamOffsetCCM) * cfg['fm_gain_height'] + fm_beam_height = fm_height + pos['fm_try'] = {'value': fm_height} + + # TRX + if cfg['fm_stripe'] in 'Rh (flat)': + x_flat = - bl.fm.xFlat[0] + else: + x_flat = - bl.fm.xFlat[1] + pos['fm_trx'] = {'value': x_flat} + + else: + raise Exception('FM Stripe selection not valid') + + ## Slits 2 + d = bl.opSlits2.center[1] - bl.fm.center[1] + sl2_beam_height = fm_beam_height - d * np.tan(-(2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch'])) + pos['sl2_centery'] = {'value': sl2_beam_height} + + ## Beam Monitor 2 + d = bl.opBM2.center[1] - bl.fm.center[1] + bm2_beam_height = fm_beam_height - d * np.tan(-(2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch'])) + pos['bm2_try'] = {'value': bm2_beam_height} + + ## Optical Table / Exit Window + + # TRY + d = bl.ehWindow.center[1] - bl.fm.center[1] + ot_height = fm_beam_height - d * np.tan(-(2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch'])) + # logger.info(fm_height) + # logger.info(d * np.tan((2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch']))) + pos['ot_try'] = {'value': ot_height} + + # Pitch + ot_pitch = - (2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch']) + pos['ot_rotx'] = {'value': ot_pitch * 1e3} + + # TRZ ES1 + ot_es1_trz = cfg['smpl'] + pos['ot_es1_trz'] = {'value': ot_es1_trz} + + return pos diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py new file mode 100644 index 0000000..56b4ebc --- /dev/null +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -0,0 +1,365 @@ +import sys +import datetime +import numpy as np +from bec_lib import bec_logger +# pylint: disable=E0611 +from qtpy.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QDoubleSpinBox, QGroupBox, QApplication, QLineEdit, QLayout +) +# pylint: disable=E0611 +from qtpy.QtCore import QTimer, Qt +from qtpy.QtGui import QColor +import pyqtgraph as pg + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot + +from debye_bec.bec_widgets.widgets.qt_widgets import InputNumberField, ComboBox, Group + +logger = bec_logger.logger + +class DigitalTwin(BECWidget, QWidget): + """ + A simple BEC widget with: + - Two numeric inputs (A, B) + - Two computed outputs (Sum, Product) + - A live plot that updates every second + """ + + USER_ACCESS = ["set_a", "set_b"] + PLUGIN = True + ICON_NAME = "lightbulb" + + def __init__(self, parent=None, *arg, **kwargs): + super().__init__(parent=parent, theme_update=True, *arg, **kwargs) + self.get_bec_shortcuts() + + self._history = [] # stores (sum, product) over time + self._t = 0 # tick counter + + central = QWidget() + self.root_layout = QHBoxLayout(central) + + self.plot_widget = PlotWidget(title='Plot title', chart_data = []) + self.control_panel = InputPanel() + + self.root_layout.addWidget(self.plot_widget, stretch=3) + self.root_layout.addWidget(self.control_panel, stretch=1, alignment=Qt.AlignTop) + + self.setLayout(self.root_layout) + self.setWindowTitle("Digital Twin") + self.resize(600, 500) + + # self.init_ui() + # self._recalculate() # populate outputs on startup + + # Timer: update plot every 1 second + # self._timer = QTimer(self) + # self._timer.setInterval(1000) + # self._timer.timeout.connect(self._tick) + # self._timer.start() + + # ------------------------------------------------------------------ UI --- + + # def init_ui(self): + + # self.spin_a = InputNumberField('Acceptance 1') + # self.spin_b = InputNumberField('Acceptance 2') + # self.input_group = Group( + # 'Inputs', + # [ + # self.spin_a, + # self.spin_b, + # ] + # ) + # self.root_layout.addWidget(self.input_group) + # self.root_layout.addStretch() + + + # root = QVBoxLayout(self) + + # # --- Inputs --- + # input_group = QGroupBox("Inputs") + # input_layout = QHBoxLayout(input_group) + + # self._spin_a = QLineEdit() + # self._spin_a.setPlaceholderText('0') + # self._spin_a.setText('0') + # # self._spin_a.setRange(-1e6, 1e6) + # # self._spin_a.setDecimals(3) + # # self._spin_a.setValue(1.0) + # # self._spin_a.setSingleStep(0.1) + + # self._spin_b = QLineEdit() + # self._spin_b.setPlaceholderText('0') + # self._spin_b.setText('0') + # # self._spin_b.setRange(-1e6, 1e6) + # # self._spin_b.setDecimals(3) + # # self._spin_b.setValue(2.0) + # # self._spin_b.setSingleStep(0.1) + + # self._spin_c = QLineEdit() + # self._spin_c.setPlaceholderText('0') + # self._spin_c.setText('10') + + # input_layout.addWidget(QLabel("A:")) + # input_layout.addWidget(self._spin_a) + # input_layout.addWidget(QLabel("B:")) + # input_layout.addWidget(self._spin_b) + # input_layout.addWidget(QLabel("C:")) + # input_layout.addWidget(self._spin_c) + # root.addWidget(input_group) + + # # --- Outputs --- + # output_group = QGroupBox("Outputs") + # output_layout = QHBoxLayout(output_group) + + # self._label_sum = QLabel("Sum: —") + # self._label_product = QLabel("Product: —") + # output_layout.addWidget(self._label_sum) + # output_layout.addWidget(self._label_product) + # root.addWidget(output_group) + + # # --- Plot --- + # plot_group = QGroupBox("Live History (updates every 1 s)") + # plot_layout = QVBoxLayout(plot_group) + + # self._plot_widget = pg.PlotWidget() + # self._plot_widget.setBackground("w") + # self._plot_widget.addLegend() + # self._plot_widget.setLabel("left", "Value") + # self._plot_widget.setLabel("bottom", "Tick") + + # self._curve_sum = self._plot_widget.plot( + # pen=pg.mkPen("b", width=2), name="Sum" + # ) + # self._curve_product = self._plot_widget.plot( + # pen=pg.mkPen("r", width=2), name="Product" + # ) + # plot_layout.addWidget(self._plot_widget) + # plot_group.setLayout(plot_layout) + # root.addWidget(plot_group) + + # self.setLayout(root) + # self.setWindowTitle("BEC Calculator Widget") + # self.resize(600, 500) + + # # Connect inputs → recalculate + # self._spin_a.editingFinished .connect(self._recalculate) + # self._spin_b.editingFinished .connect(self._recalculate) + + # ---------------------------------------------------------- Logic --- + + # @SafeSlot() + # def _recalculate(self): + # # logger.info(var) + # a = float(self._spin_a.text()) + # b = float(self._spin_b.text()) + # s = a + b + # p = a * b + # self._label_sum.setText(f"Sum: {s:.4f}") + # self._label_product.setText(f"Product: {p:.4f}") + # self._current_sum = s + # self._current_product = p + + # @SafeSlot() + # def _tick(self): + # """Called every second: record current outputs and refresh plot.""" + # self._history.append((self._t, self._current_sum, self._current_product)) + # self._t += 1 + + # ticks = [h[0] for h in self._history] + # sums = [h[1] for h in self._history] + # products = [h[2] for h in self._history] + + # self._curve_sum.setData(ticks, sums) + # self._curve_product.setData(ticks, products) + + # # --------------------------------------------------- RPC interface --- + + # def set_a(self, value: float): + # """Set input A remotely from the BEC CLI.""" + # self._spin_a.setValue(value) + + # def set_b(self, value: float): + # """Set input B remotely from the BEC CLI.""" + # self._spin_b.setValue(value) + + +class InputPanel(QWidget): + """Right-side control panel: input field, indicator, send, recording.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._layout = QVBoxLayout(self) + self._layout.setSizeConstraint(QLayout.SetFixedSize) + + self.energy = InputNumberField('Energy [keV]') + + self.sldi_hacc = InputNumberField('Horizontal [± mrad]') + self.sldi_vacc = InputNumberField('Vertical [± mrad]') + self.fe_slits_group = Group( + 'FE Slits Acceptance', + [ + self.sldi_hacc, + self.sldi_vacc, + ] + ) + + self.assistant_group = Group( + 'Assistant', + [ + self.energy, + self.fe_slits_group, + ] + ) + + self._layout .addWidget(self.assistant_group) + self._layout .addStretch() + +class PlotWidget(QWidget): + """Plot widget with two curves and legend.""" + + def __init__(self, title: str = "Title", chart_data = [], max_points=2000, parent=None): + super().__init__(parent) + self.chart_data = chart_data + self.max_points = max_points + + self._layout = QVBoxLayout(self) + self._title = QLabel(f"

{title}

") + self._layout.addWidget(self._title) + + self.plot_widget = pg.PlotWidget(axisItems={'bottom': TimeAxis(orientation='bottom')}) + self.plot_widget.getAxis('bottom').enableAutoSIPrefix(False) + self.plot_widget.addLegend() + + self.curves = [] + colors = self.golden_angle_color( + colormap='plasma', num=max(10, len(self.curves) + 1), format="HEX" + ) + + for idx, element in enumerate(self.chart_data): + self.curves.append( + self.plot_widget.plot( + [], + [], + pen=pg.mkPen(color=colors[idx], width=2), + name=element, + ) + ) + + self._layout.addWidget(self.plot_widget) + + self.plot_widget.setLabel('left', 'Temperature [°C]') + self.plot_widget.setLabel('bottom', 'Time') + + def golden_angle_color( + self, + colormap: str, + num: int, + format="QColor", + theme_offset=0.2, + theme=None, + ) -> list: + """ + Extract num colors from the specified colormap following golden angle distribution and return them in the specified format. + + Args: + colormap (str): Name of the colormap. + num (int): Number of requested colors. + format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor'). + theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background. + + Returns: + list: List of colors in the specified format. + + Raises: + ValueError: If theme_offset is not between 0 and 1. + """ + + cmap = pg.colormap.get(colormap) + phi = (1 + np.sqrt(5)) / 2 # Golden ratio + golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125 + + min_pos, max_pos = self.set_theme_offset(theme, theme_offset) + + # Generate positions within the acceptable range + positions = np.mod(np.arange(num) * golden_angle_conjugate, 1) + positions = min_pos + positions * (max_pos - min_pos) + + # Sample colors from the colormap at the calculated positions + colors = cmap.map(positions, mode="float") + color_list = [] + + for color in colors: + if format.upper() == "HEX": + color_list.append(QColor.fromRgbF(*color).name()) + elif format.upper() == "RGB": + color_list.append(tuple((np.array(color) * 255).astype(int))) + elif format.upper() == "QCOLOR": + color_list.append(QColor.fromRgbF(*color)) + else: + raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.") + return color_list + + def set_theme_offset(self, theme = None, offset=0.2) -> tuple: + """ + Set the theme offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background. + + Args: + theme(str): The theme to be applied. + offset(float): Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background. + + Returns: + tuple: Tuple of min_pos and max_pos. + + Raises: + ValueError: If theme_offset is not between 0 and 1. + """ + + if offset < 0 or offset > 1: + raise ValueError("theme_offset must be between 0 and 1") + + if theme is None: + app = QApplication.instance() + if hasattr(app, "theme"): + theme = app.theme.theme + + if theme == "light": + min_pos = 0.0 + max_pos = 1 - offset + else: + min_pos = 0.0 + offset + max_pos = 1.0 + + return min_pos, max_pos + + def update_curves(self, timestamps: list[str], data: list[float]): + x = timestamps.copy() + y = data.copy() + min_len = min([min([len(i) for i in y]), len(x)]) + x_float = [t.timestamp() for t in x] + for idx, element in enumerate(y): + self.curves[idx].setData(x=np.array(x_float)[0:min_len], y=np.array(element)[0:min_len]) + +class TimeAxis(pg.AxisItem): + def tickStrings(self, values, scale, spacing): + return [datetime.fromtimestamp(value).strftime("%H:%M:%S") for value in values] + + +# --------------------------------------------------------- Standalone run --- + +if __name__ == "__main__": + from qtpy.QtWidgets import QApplication + from bec_widgets.utils import BECDispatcher + from bec_widgets.utils.colors import apply_theme + + app = QApplication(sys.argv) + apply_theme("dark") + dispatcher = BECDispatcher(gui_id="digital_twin") + win = DigitalTwin() + + win.resize(1000, 800) + win.show() + sys.exit(app.exec_()) \ No newline at end of file diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.pyproject b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.pyproject new file mode 100644 index 0000000..226dc5f --- /dev/null +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.pyproject @@ -0,0 +1 @@ +{'files': ['digital_twin.py']} \ No newline at end of file diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin_plugin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin_plugin.py new file mode 100644 index 0000000..2decf97 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from bec_widgets.utils.bec_designer import designer_material_icon +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from debye_bec.bec_widgets.widgets.digital_twin.digital_twin import DigitalTwin + +DOM_XML = """ + + + + +""" + + +class DigitalTwinPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = DigitalTwin(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(DigitalTwin.ICON_NAME) + + def includeFile(self): + return "digital_twin" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "DigitalTwin" + + def toolTip(self): + return "DigitalTwin" + + def whatsThis(self): + return self.toolTip() diff --git a/debye_bec/bec_widgets/widgets/digital_twin/register_digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/register_digital_twin.py new file mode 100644 index 0000000..0c5d315 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/digital_twin/register_digital_twin.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from debye_bec.bec_widgets.widgets.digital_twin.digital_twin_plugin import DigitalTwinPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(DigitalTwinPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py b/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py new file mode 100644 index 0000000..650e84f --- /dev/null +++ b/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py @@ -0,0 +1,289 @@ +""" +X01DA / Debye Beamline Parameters. +This file describes the parameter of each component of the Debye beamline +to be used for raytracing and geometrical calculations. +""" + +import os +import numpy as np +from collections import namedtuple + +if os.environ.get("USE_XRT", "True").lower() in ("1", "true", "yes"): + import xrt.backends.raycing.materials as rm # type: ignore +else: + class _DummyClass: + def __init__(self, *args, **kwargs): + pass + class _DummyMaterials: + Material = _DummyClass + CrystalSi = _DummyClass + rm = _DummyMaterials() + +# XRT definitions +filterBeryl = rm.Material('Be', rho=1.85, kind='plate') # pyright: ignore[reportArgumentType] +filterDiamond = rm.Material('C', rho=3.52, kind='plate') # pyright: ignore[reportArgumentType] +filterGraphite = rm.Material('C', rho=2.266, kind='plate') # pyright: ignore[reportArgumentType] + +stripeSi = rm.Material('Si', rho=2.33) # pyright: ignore[reportArgumentType] +stripePt = rm.Material('Pt', rho=21.45) # pyright: ignore[reportArgumentType] +stripeRh = rm.Material('Rh', rho=12.41) # pyright: ignore[reportArgumentType] +stripeCr = rm.Material('Cr', rho=7.14) # pyright: ignore[reportArgumentType] +stripePyrex = rm.Material('Si', rho=2.20) # Use Si as bare element and the density of SiO2 # pyright: ignore[reportArgumentType] + +si111_1 = rm.CrystalSi(hkl=(1, 1, 1), tK=77) # first xtal surface +si311_1 = rm.CrystalSi(hkl=(3, 1, 1), tK=77) # first xtal surface +si333_1 = rm.CrystalSi(hkl=(3, 3, 3), tK=77) # first xtal surface +si511_1 = rm.CrystalSi(hkl=(5, 1, 1), tK=77) # first xtal surface +si111_2 = rm.CrystalSi(hkl=(1, 1, 1), tK=77) # second xtal surface +si311_2 = rm.CrystalSi(hkl=(3, 1, 1), tK=77) # second xtal surface +si333_2 = rm.CrystalSi(hkl=(3, 3, 3), tK=77) # second xtal surface +si511_2 = rm.CrystalSi(hkl=(5, 1, 1), tK=77) # second xtal surface + +filterDiamond = rm.Material('C', rho=3.52, kind='plate') # pyright: ignore[reportArgumentType] +filterBe = rm.Material('Be', rho=1.85, kind='plate') # pyright: ignore[reportArgumentType] +filterSi3N4 = rm.Material(['Si', 'N'], quantities=[3, 4], rho=3.44, kind='plate') # pyright: ignore[reportArgumentType] +filterAl = rm.Material('Al', rho=2.69, kind='plate') # pyright: ignore[reportArgumentType] +filterGraphite = rm.Material('C', rho=2.266, kind='plate') # pyright: ignore[reportArgumentType] + +# General parameters +sourceHeight = 0 + +#Synchrotron +synchrotron = namedtuple('synchrotron', ['eE', 'eI', 'eEspread', + 'eEpsilonX', 'eEpsilonZ', 'betaX', 'betaZ']) + +sls1 = synchrotron( + eE = 2.4, + eI = 0.4, + eEspread=0.878e-3, + eEpsilonX=5.63, + eEpsilonZ=0.007, + betaX=0.45, + betaZ=14.4, + ) + +sls2 = synchrotron( + eE=2.7, + eI=0.4, + eEspread=1.147e-3, + eEpsilonX=0.156, + eEpsilonZ=0.01, + betaX=0.18, + betaZ=4.6, + ) + +# Source +bendingMagnet = namedtuple('bendingMagnet', ['name', 'center', 'sync', 'B0']) + +sls1_14t = bendingMagnet( + name='FE-BM-SLS1-1.4T', + center=(0, 0, 0), + sync=sls1, + B0=1.4,) + +sls2_21t = bendingMagnet( + name='FE-BM-SLS2-2.1T', + center=(0, 0, 0), + sync=sls2, + B0=2.1,) + +sls2_35t = bendingMagnet( + name='FE-BM-SLS2-3.5T', + center=(0, 0, 0), + sync=sls2, + B0=3.5,) + +sls2_50t = bendingMagnet( + name='FE-BM-SLS2-5.0T', + center=(0, 0, 0), + sync=sls2, + B0=5.0,) + +# FE slits +fe_slits = namedtuple('slits', ['name', 'center', 'center1', 'center2', 'maxDivH', 'maxDivV']) + +feSlits = fe_slits( + name='FE-SLITS', + center=(0, 6117, sourceHeight), + center1=(0, 5045, sourceHeight), + center2=(0, 5289.5, sourceHeight), + maxDivH=1.8e-3, + maxDivV=0.8e-3,) + +# FE Window +filt = namedtuple('filt', ['name', 'center', 'pitch', 'limPhysX', 'limPhysY', 'surface', 'material', 'thickness']) + +feWindow = filt( + name='FE-WINDOW', + center=(0., 7020, sourceHeight), + pitch=np.pi/2, + limPhysX=(-6, 6), + limPhysY=(-3., 3.), + surface='None', + material=filterDiamond, + thickness=0.1,) +feWindow = feWindow._replace(surface=f'CVD Diamond window {feWindow.thickness*1e3:0.0f} $\\mu$m') + +# Collimating mirror +collimatingMirror = namedtuple('collimatingMirror', ['name', + 'center', 'surface', 'material', 'limPhysX', 'limPhysY', + 'limOptX', 'limOptY', 'R', 'pitch', 'jack1', 'jack2', 'jack3', + 'tx1', 'tx2']) + +cm = collimatingMirror( + name='FE-CM', + center=[0, 6890, sourceHeight], + surface=('Si','Pt','Rh'), + material=(stripeSi, stripePt, stripeRh), + limPhysX=(-34, 34), + limPhysY=(-600, 600), + limOptX=((-27, -3.5, 15), (-11, 6.5, 25)), + limOptY=((-500, -500, -500), (500, 500, 500)), + R=[3e6, 15e6], + pitch=[-5.0e-3, -0.0e-3], + jack1=[0., 7210., 0.], #Tripod X, Y, Z (global) + jack2=[-210., 8310., 0.], + jack3=[210., 8310., 0.], + tx1=[0.0, -575.5], # X-Stage 1 [x, y] (local) + tx2=[0.0, 575],) # X-Stage 2 + +apertures = namedtuple('apertures', ['name', 'center', 'opening']) + +fePS = apertures( + name='FE-PS', + center=[0, 8815, sourceHeight], + opening=[-20., 20., -20.+12.5, 20.+12.5]) # left, right, bottom, top + +opWbBsBlock = apertures( + name='OP-WB-BS-BLOCK', + center=[0., 13860, sourceHeight], + opening=[-18., 18., 25, 85.5]) # left, right, bottom, top + # opening=[-18., 18., 42, 76], # X10DA + +# Monochromator +monochromator = namedtuple('monochromator', ['name', 'center', + 'xtal', 'material1', 'material2', 'xtalWidth', 'xtalOffsetX', + 'xtalLength1', 'xtalLength2', 'xtalGap', 'rotOffset', + 'heightOffset', 'braggLim', 'jack1', 'jack2', 'jack3', 'tx']) + +mo1 = monochromator( + name='OP-MO1', + center=[0., 11750, sourceHeight], + xtal=('Si311','Si111'), + material1=(si311_1, si111_1), + material2=(si311_2, si111_2), + xtalWidth = (24, 24), + xtalOffsetX=(-21.2, 21.2), + xtalLength1 = (55, 55), + xtalLength2 = (105, 105), + xtalGap = (8, 8), + rotOffset = 6, + heightOffset = 8.5, + braggLim = [3.6, 33], + jack1=[0., 11350., 0.], #Tripod maybe not available! + jack2=[-400., 12350., 0.], + jack3=[400., 12350., 0.], + tx=0.0,) # X-Stage [x] + +mo2 = monochromator( + name='OP-CCM2', + center=[0., 13250, sourceHeight], + xtal=('Si311','Si111'), + material1=(si311_1, si111_1), + material2=(si311_2, si111_2), + xtalWidth = (24, 24), + xtalOffsetX=(-21, 21), + xtalLength1 = (55, 55), + xtalLength2 = (105, 105), + xtalGap = (8, 8), + rotOffset = 6, + heightOffset = 8.5, + braggLim = [3.6, 33], + jack1=[0., 13350., 0.], #Tripod maybe not available! + jack2=[-400., 14350., 0.], + jack3=[400., 14350., 0.], + tx=0.0,) # X-Stage [x] + +# OP Slits +op_slits = namedtuple('op_slits', ['name', 'center']) + +opSlits1 = op_slits( + name='OP-SLITS 1', + center=(0, 14349.6, sourceHeight), +) + +opSlits2 = op_slits( + name='OP-SLITS 2', + center=(0, 18134.8, sourceHeight), +) + +# OP Beam Monitors +op_bm = namedtuple('op_bm', ['name', 'center']) + +opBM1 = op_bm( + name='OP Beam Monitor 1', + center=(0, 14599.6, sourceHeight), +) + +opBM2 = op_bm( + name='OP Beam Monitor 2', + center=(0, 18384.8, sourceHeight), +) + +# Focusing mirror +focusingMirror = namedtuple('focusingMirror', ['name', 'center', + 'surfaceToroid', 'materialToroid', 'surfaceFlat', 'materialFlat', + 'limPhysXToroid', 'limPhysYToroid', 'limPhysXFlat', 'limPhysYFlat', + 'limOptXToroid', 'limOptYToroid', 'limOptXFlat', 'limOptYFlat', + 'R', 'pitch', 'r', 'xToroid', 'xFlat', 'hToroid', 'jack1', 'jack2', 'jack3', + 'tx1', 'tx2']) + +fm = focusingMirror( + name='OP-FM', + center=[0., 15670, sourceHeight], # nominal height 58 mm above ring, SLS1! + surfaceToroid=('Rh', 'Pt'), + materialToroid=(stripeRh, stripePt), + surfaceFlat=('Rh', 'Pt'), + materialFlat=(stripeRh, stripePt), + limPhysXToroid=(-79., 79.), + limPhysYToroid=(-575., 575.), + limPhysXFlat=(-79., 79.), + limPhysYFlat=(-575., 575.), + limOptXToroid=((-38, 66), (-66, 31)), + limOptYToroid=((-500., -500.), (500., 500.)), + limOptXFlat=((-11.45, 23.55), (-30.45, -6.45)), + limOptYFlat=((-500., -500.), (500., 500.)), + R=[3e6, 15e6], + pitch=[-5.0e-3, 0e-3], + r=[35.510, 24.986], + xToroid=[-52, 48.5], # offset in local x + xFlat = [-20.95, 8.55], + hToroid=[2.88, 7.15], # depth of the cylinder at x = xCylinder1 and x = xCylinder2. + jack1=[-130., 15535-538., 0.], + jack2=[130., 15535+538., 0.], + jack3=[0., 15535+538., 0.], + tx1=[0., -575.], # X-Stage 1 [x, y] + tx2=[0., 575.],) # X-Stage 2 [x, y] + +# EH Window +ehWindow = filt( + name='EH-WINDOW', + center=(0., 19998.3, sourceHeight), + pitch=np.pi/2, + limPhysX=(-20., 20.), + limPhysY=(-4, 4), + surface='None', + material=filterSi3N4, + thickness=0.002,) +ehWindow = ehWindow._replace(surface=f'Beryllium window {ehWindow.thickness*1e3:0.0f} $\\mu$m') + +# Sample +sample = namedtuple('sample', ['name', 'center']) + +smpl = sample( + name='EH-SMPL', + center=[0, 23365, sourceHeight],) + +smpl2 = sample( + name='EH-SMPL2', + center=[0, 27500, sourceHeight],) diff --git a/debye_bec/bec_widgets/widgets/qt_widgets.py b/debye_bec/bec_widgets/widgets/qt_widgets.py new file mode 100644 index 0000000..4273cce --- /dev/null +++ b/debye_bec/bec_widgets/widgets/qt_widgets.py @@ -0,0 +1,273 @@ + +from functools import partial +# pylint: disable=E0611 +from qtpy.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QGroupBox, QComboBox, QApplication, QDoubleSpinBox +) +from qtpy.QtGui import QFont + +class Group(QGroupBox): + def __init__(self, label, widgets): + super().__init__(label) + self.layout = QVBoxLayout(self) + for widget in widgets: + self.layout.addWidget(widget) + +class Indicator(QWidget): + def __init__(self, label, unit=None, highlight=False): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 0, 0, 0) + layout.setSpacing(0) + self.label = QLabel(label) + self.label.setFixedWidth(150) + layout.addWidget(self.label) + self.value = QLabel('-') + self.value.setFixedWidth(160) + layout.addWidget(self.value) + self.unit = unit + self.highlight = highlight + if highlight: + font = QFont() + font.setBold(True) + font.setPointSize(14) + self.label.setFont(font) + self.value.setFont(font) + + def set_text(self, text): + if self.unit is not None: + text = text + ' ' + self.unit + self.value.setText(text) + +class InputTextField(QWidget): + def __init__(self, topic, label): + super().__init__() + self.topic = topic + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 0, 0, 0) + layout.setSpacing(0) + self.label = QLabel(label) + self.label.setFixedWidth(150) + layout.addWidget(self.label) + self.value = QLineEdit() + self.value.setPlaceholderText('0') + self.value.setFixedWidth(160) + layout.addWidget(self.value) + + def set_text(self, text): + self.value.setText(text) + + def has_focus(self) -> bool: + return self.value.hasFocus() + + def set_on_return(self, func): + """Connect a function to the Enter/Return key press.""" + self.value.returnPressed.connect( + partial(func, self.value, self.topic, lambda: self.value.text()) + ) + +class InputNumberField(QWidget): + def __init__(self, label, init=0, decimals=1, single_step=0.1, ll=-1e6, hl=1e6): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 0, 0, 0) + layout.setSpacing(0) + self.label = QLabel(label) + self.label.setFixedWidth(150) + layout.addWidget(self.label) + self.value = QDoubleSpinBox() + self.value.setValue(init) + self.value.setRange(ll, hl) + self.value.setDecimals(decimals) + self.value.setSingleStep(single_step) + self.value.setFixedWidth(160) + layout.addWidget(self.value) + + def set_number(self, number): + self.value.setValue(number) + + def has_focus(self) -> bool: + return self.value.hasFocus() + + def set_on_return(self, func): + """Connect a function to the Enter/Return key press.""" + self.value.editingFinished.connect( + partial(func, self.value, lambda: self.value.text()) + ) + +class IPAdressInputField(QWidget): + def __init__(self, topic, label): + super().__init__() + self.topic = topic + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 0, 0, 0) + layout.setSpacing(0) + self.label = QLabel(label) + self.label.setFixedWidth(150) + layout.addWidget(self.label) + self.oct0 = QLineEdit() + self.oct0.setPlaceholderText('0') + self.oct0.setFixedWidth(30) + layout.addWidget(self.oct0) + separator1 = QLabel('.') + layout.addWidget(separator1) + self.oct1 = QLineEdit() + self.oct1.setPlaceholderText('0') + self.oct1.setFixedWidth(30) + layout.addWidget(self.oct1) + separator2 = QLabel('.') + layout.addWidget(separator2) + self.oct2 = QLineEdit() + self.oct2.setPlaceholderText('0') + self.oct2.setFixedWidth(30) + layout.addWidget(self.oct2) + separator3 = QLabel('.') + layout.addWidget(separator3) + self.oct3 = QLineEdit() + self.oct3.setPlaceholderText('0') + self.oct3.setFixedWidth(30) + layout.addWidget(self.oct3) + + self.oct0.editingFinished.connect(partial(self.check_octet, self.oct0)) + self.oct1.editingFinished.connect(partial(self.check_octet, self.oct1)) + self.oct2.editingFinished.connect(partial(self.check_octet, self.oct2)) + self.oct3.editingFinished.connect(partial(self.check_octet, self.oct3)) + + def check_octet(self, octet): + if octet.text().isnumeric(): + if int(octet.text()) < 0: + octet.setText('0') + if int(octet.text()) > 254: + octet.setText('254') + else: + octet.setText('') + + def get_ip(self): + return f'{self.oct0.text()}.{self.oct1.text()}.{self.oct2.text()}.{self.oct3.text()}' + + def set_ip(self, ip): + octets = ip.split('.') + if len(octets) == 4 and all(octet.isnumeric() for octet in octets): + if all(int(octet) > 0 and int(octet) < 254 for octet in octets): + self.oct0.setText(octets[0]) + self.oct1.setText(octets[1]) + self.oct2.setText(octets[2]) + self.oct3.setText(octets[3]) + +class ComboBox(QWidget): + def __init__(self, enums, label): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 0, 0, 0) + layout.setSpacing(0) + self.label = QLabel(label) + self.label.setFixedWidth(150) + layout.addWidget(self.label) + self.value = QComboBox() + self.value.setFixedWidth(160) + for entry in enums: + self.value.addItem(entry) + layout.addWidget(self.value) + + def set_current_text(self, text): + self.value.setCurrentText(text) + + def has_focus(self) -> bool: + return QApplication.focusWidget() is self.value.view() + + def set_on_change(self, func, reset_plot=False): + """Connect a function to the Enter/Return key press.""" + self.value.activated.connect( + partial(func, self.value, lambda: self.value.currentText(), reset_plot) + ) + +class LED(QWidget): + def __init__(self, states, colors, label): + super().__init__() + self.states = states + self.colors = colors + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 0, 0, 0) + layout.setSpacing(0) + self.label = QLabel(label) + self.label.setFixedWidth(150) + layout.addWidget(self.label) + self.led = QLabel() + self.led.setFixedWidth(160) + layout.addWidget(self.led) + + def apply_color(self, val): + color = self.colors[self.states.index(val)] + self.led.setStyleSheet(f"background-color: {color}; border: 1px solid black;") + +class StartStop(QWidget): + def __init__(self, label, label_buttons=['Start', 'Stop']): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 0, 0, 0) + layout.setSpacing(0) + self.label = QLabel(label) + self.label.setFixedWidth(150) + layout.addWidget(self.label) + self.start = QPushButton(label_buttons[0]) + self.start.setStyleSheet("color: black; background-color: green;") + self.start.setFixedWidth(80) + self.stop = QPushButton(label_buttons[1]) + self.stop.setStyleSheet("color: black; background-color: firebrick;") + self.stop.setFixedWidth(80) + layout.addWidget(self.start) + layout.addWidget(self.stop) + + def set_on_start(self, func): + """Connect a function to the start button press.""" + self.start.clicked.connect(func) + + def set_on_stop(self, func): + """Connect a function to the stop button press.""" + self.stop.clicked.connect(func) + + def enable_start(self): + self.start.setEnabled(True) + self.start.setStyleSheet("color: black; background-color: green;") + + def enable_stop(self): + self.stop.setEnabled(True) + self.stop.setStyleSheet("color: black; background-color: firebrick;") + + def disable_start(self): + self.start.setEnabled(False) + self.start.setStyleSheet("color: black; background-color: grey;") + + def disable_stop(self): + self.stop.setEnabled(False) + self.stop.setStyleSheet("color: black; background-color: grey;") + +class Button(QWidget): + def __init__(self, label, label_button): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 0, 0, 0) + layout.setSpacing(0) + self.label = QLabel(label) + self.label.setFixedWidth(150) + layout.addWidget(self.label) + self.button = QPushButton(label_button) + self.button.setStyleSheet("color: black; background-color: dodgerblue;") + self.button.setFixedWidth(160) + layout.addWidget(self.button) + + def set_on_press(self, func): + """Connect a function to the button press.""" + self.button.clicked.connect(func) + + def enable_button(self): + self.button.setEnabled(True) + self.button.setStyleSheet("color: black; background-color: dodgerblue;") + + def disable_button(self): + self.button.setEnabled(False) + self.button.setStyleSheet("color: black; background-color: grey;") + + def set_button_text(self, text): + self.button.setText(text) From 101954476c95fc111f3426189dc3d72ecadb1810 Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 29 Apr 2026 16:36:52 +0200 Subject: [PATCH 28/54] wip: digital twin --- .../digital_twin/calculate_positions.py | 2 +- .../widgets/digital_twin/digital_twin.py | 253 +++++++++++- debye_bec/bec_widgets/widgets/qt_widgets.py | 367 ++++++++++-------- 3 files changed, 447 insertions(+), 175 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py b/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py index f15b289..d155243 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py @@ -3,7 +3,7 @@ import numpy as np from bec_lib import bec_logger os.environ["USE_XRT"] = "False" -import debye_bec.bec_ipython_client.plugins.digital_twin.x01da_parameters as bl +import debye_bec.bec_widgets.widgets.digital_twin.x01da_parameters as bl logger = bec_logger.logger diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 56b4ebc..33b1f18 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -15,7 +15,9 @@ import pyqtgraph as pg from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot -from debye_bec.bec_widgets.widgets.qt_widgets import InputNumberField, ComboBox, Group +from debye_bec.bec_widgets.widgets.qt_widgets import InputNumberField, ComboBox, Group, NumberIndicator + +from debye_bec.bec_widgets.widgets.digital_twin.calculate_positions import calculate_positions logger = bec_logger.logger @@ -42,15 +44,63 @@ class DigitalTwin(BECWidget, QWidget): self.root_layout = QHBoxLayout(central) self.plot_widget = PlotWidget(title='Plot title', chart_data = []) - self.control_panel = InputPanel() + self.input = InputPanel() + self.positions_panel = PositionsPanel() self.root_layout.addWidget(self.plot_widget, stretch=3) - self.root_layout.addWidget(self.control_panel, stretch=1, alignment=Qt.AlignTop) + self.root_layout.addWidget(self.input, stretch=1, alignment=Qt.AlignTop) + self.root_layout.addWidget(self.positions_panel, stretch=1, alignment=Qt.AlignTop) self.setLayout(self.root_layout) self.setWindowTitle("Digital Twin") self.resize(600, 500) + self.input.energy.value_changed_connect(self.calculate_positions) + self.input.sldi_hacc.value_changed_connect(self.calculate_positions) + self.input.sldi_vacc.value_changed_connect(self.calculate_positions) + self.input.cm_stripe.activated_connect(self.calculate_positions) + self.input.cm_pitch.value_changed_connect(self.calculate_positions) + self.input.mo1_mode.activated_connect(self.calculate_positions) + self.input.mo1_xtal.activated_connect(self.calculate_positions) + self.input.fm_stripe.activated_connect(self.calculate_positions) + self.input.fm_pitch.value_changed_connect(self.calculate_positions) + self.input.smpl.value_changed_connect(self.calculate_positions) + + self.input.mo1_mode.activated_connect(self.update_mono_mode) + + @SafeSlot() + def calculate_positions(self, field, qt_obj, number, *args): + logger.info(f'Got field {field} and number {qt_obj} and number {number} and args {args}') + + config = { # Config in SI units! + 'energy' : self.input.energy.value(), + 'h_acc' : self.input.sldi_hacc.value() * 1e-3, + 'v_acc' : self.input.sldi_vacc.value() * 1e-3, + 'cm_pitch' : -self.input.cm_pitch.value() * 1e-3, + 'cm_stripe' : self.input.cm_stripe.currentText(), + 'mo_mode' : self.input.mo1_mode.currentText(), + 'mo_xtal' : self.input.mo1_xtal.currentText(), + 'mo_bragg' : [12.725, 12.725], + 'fm_pitch' : -self.input.fm_pitch.value() * 1e-3, + 'fm_stripe' : self.input.fm_stripe.currentText(), + 'fm_gain_height' : 1, + 'smpl' : self.input.smpl.value(), + } + + logger.info(f'Config created: {config}') + + positions = calculate_positions(config) + + logger.info(f'Got positions: {positions}') + + @SafeSlot() + def update_mono_mode(self, *args): + logger.info(f'Got args {args}') + if self.input.mo1_mode.currentText() in 'Monochromatic': + self.input.mo1_xtal.setDisabled(False) + else: + self.input.mo1_xtal.setDisabled(True) + # self.init_ui() # self._recalculate() # populate outputs on startup @@ -195,11 +245,13 @@ class InputPanel(QWidget): self._layout = QVBoxLayout(self) self._layout.setSizeConstraint(QLayout.SetFixedSize) - self.energy = InputNumberField('Energy [keV]') + # Energy + self.energy = InputNumberField('Energy [keV]', init=8979, decimals=0, single_step=100, ll=4000, hl=65000) - self.sldi_hacc = InputNumberField('Horizontal [± mrad]') - self.sldi_vacc = InputNumberField('Vertical [± mrad]') - self.fe_slits_group = Group( + # FE Slits Acceptance + self.sldi_hacc = InputNumberField('Horizontal [± mrad]', init=0.25, decimals=2, single_step=0.01, ll=-0.1, hl=0.9) + self.sldi_vacc = InputNumberField('Vertical [± mrad]', init=0.1, decimals=2, single_step=0.01, ll=-0.1, hl=0.5) + self.sldi_ass_group = Group( 'FE Slits Acceptance', [ self.sldi_hacc, @@ -207,15 +259,190 @@ class InputPanel(QWidget): ] ) - self.assistant_group = Group( - 'Assistant', + # Collimating mirror + self.cm_stripe = ComboBox('Stripe', ['Si', 'Rh', 'Pt']) + self.cm_pitch_critical = NumberIndicator('Critical Pitch', 'mrad', decimals=3) + self.cm_pitch = InputNumberField('Pitch [mrad]', init=-3, decimals=3, single_step=0.01, ll=-4.6, hl=-1.2) + self.cm_ass_group = Group( + 'Collimating Mirror', [ - self.energy, - self.fe_slits_group, + self.cm_stripe, + self.cm_pitch_critical, + self.cm_pitch, ] ) - self._layout .addWidget(self.assistant_group) + # Monochromator + self.mo1_mode = ComboBox('Mode', ['Monochromatic', 'Pinkbeam']) + self.mo1_xtal = ComboBox('Crystal', ['Si(111)', 'Si(311)']) + self.mo1_ass_group = Group( + 'Monochromator', + [ + self.mo1_mode, + self.mo1_xtal, + ] + ) + + # Focusing Mirror + self.fm_stripe = ComboBox('Stripe', ['Rh (toroid)', 'Rh (flat)', 'Pt (toroid)', 'Pt (flat)']) + self.fm_pitch_ideal = NumberIndicator('Ideal Pitch', 'mrad', decimals=3) + self.fm_pitch = InputNumberField('Pitch [mrad]', init=-3, decimals=3, single_step=0.01, ll=-10, hl=2) + self.fm_ass_group = Group( + 'Focusing Mirror', + [ + self.fm_stripe, + self.fm_pitch_ideal, + self.fm_pitch, + ] + ) + + # Sample + self.smpl = InputNumberField('Sample Position [mm]') + + # Assemble complete assitant group + self.input_group = Group( + 'User Input', + [ + self.energy, + self.sldi_ass_group, + self.cm_ass_group, + self.mo1_ass_group, + self.fm_ass_group, + self.smpl, + ] + ) + + self._layout .addWidget(self.input_group) + self._layout .addStretch() + +class PositionsPanel(QWidget): + """Right-side control panel: input field, indicator, send, recording.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._layout = QVBoxLayout(self) + self._layout.setSizeConstraint(QLayout.SetFixedSize) + + # FE Slits + self.sldi_gapx = NumberIndicator('GAPX', 'mm', decimals=3) + self.sldi_gapy = NumberIndicator('GAPY', 'mm', decimals=3) + self.sldi_pos_group = Group( + 'FE Slits', + [ + self.sldi_gapx, + self.sldi_gapy, + ] + ) + + # Collimating mirror + self.cm_trx = NumberIndicator('TRX', 'mm', decimals=1) + self.cm_try = NumberIndicator('TRY', 'mm', decimals=3) + self.cm_bnd = NumberIndicator('BENDER', 'mm', decimals=2) + self.cm_rotx = NumberIndicator('PITCH', 'mm', decimals=3) + self.cm_pos_group = Group( + 'Collimating Mirror', + [ + self.cm_trx, + self.cm_try, + self.cm_bnd, + self.cm_rotx, + ] + ) + + # Monochromator + self.mo1_bragg_angle = NumberIndicator('Bragg Angle', 'mm', decimals=3) + self.mo1_trx = NumberIndicator('TRX', 'mm', decimals=1) + self.mo1_try = NumberIndicator('TRY', 'mm', decimals=3) + self.mo1_pos_group = Group( + 'Monochromator', + [ + self.mo1_bragg_angle, + self.mo1_trx, + self.mo1_try, + ] + ) + + # OP Slits 1 + self.sl1_centery = NumberIndicator('CENTERY', 'mm', decimals=1) + self.sl1_pos_group = Group( + 'OP Slits 1', + [ + self.sl1_centery, + ] + ) + + # OP Beam Monitor 1 + self.bm1_try = NumberIndicator('TRY', 'mm', decimals=1) + self.bm1_pos_group = Group( + 'OP Beam Monitor 1', + [ + self.bm1_try, + ] + ) + + # Focusing Mirror + self.fm_trx = NumberIndicator('TRX', 'mm', decimals=1) + self.fm_try = NumberIndicator('TRY', 'mm', decimals=3) + self.fm_bnd = NumberIndicator('BENDER', 'mm', decimals=2) + self.fm_rotx = NumberIndicator('PITCH', 'mm', decimals=3) + self.fm_pos_group = Group( + 'Focusing Mirror', + [ + self.fm_trx, + self.fm_try, + self.fm_bnd, + self.fm_rotx, + ] + ) + + # OP Slits 2 + self.sl2_centery = NumberIndicator('CENTERY', 'mm', decimals=1) + self.sl2_pos_group = Group( + 'OP Slits 2', + [ + self.sl2_centery, + ] + ) + + # OP Beam Monitor 2 + self.bm2_try = NumberIndicator('TRY', 'mm', decimals=1) + self.bm2_pos_group = Group( + 'OP Beam Monitor 2', + [ + self.bm2_try, + ] + ) + + # Optical Table + self.es_try = NumberIndicator('TRY', 'mm', decimals=3) + self.es_rotx = NumberIndicator('ROTX', 'mm', decimals=3) + self.es1_trz = NumberIndicator('ES1 TRZ', 'mm', decimals=0) + self.es_pos_group = Group( + 'Optical Table', + [ + self.es_try, + self.es_rotx, + self.es1_trz, + ] + ) + + # Assemble complete assitant group + self.position_group = Group( + 'Axes Positions Calculator', + [ + self.sldi_pos_group, + self.cm_pos_group, + self.mo1_pos_group, + self.sl1_pos_group, + self.bm1_pos_group, + self.fm_pos_group, + self.sl2_pos_group, + self.bm2_pos_group, + self.es_pos_group, + ] + ) + + self._layout .addWidget(self.position_group) self._layout .addStretch() class PlotWidget(QWidget): @@ -352,7 +579,7 @@ class TimeAxis(pg.AxisItem): if __name__ == "__main__": from qtpy.QtWidgets import QApplication - from bec_widgets.utils import BECDispatcher + from bec_widgets.utils.bec_dispatcher import BECDispatcher from bec_widgets.utils.colors import apply_theme app = QApplication(sys.argv) diff --git a/debye_bec/bec_widgets/widgets/qt_widgets.py b/debye_bec/bec_widgets/widgets/qt_widgets.py index 4273cce..82ba6da 100644 --- a/debye_bec/bec_widgets/widgets/qt_widgets.py +++ b/debye_bec/bec_widgets/widgets/qt_widgets.py @@ -14,8 +14,34 @@ class Group(QGroupBox): for widget in widgets: self.layout.addWidget(widget) -class Indicator(QWidget): - def __init__(self, label, unit=None, highlight=False): +# class TextIndicator(QWidget): +# def __init__(self, label, unit=None, highlight=False): +# super().__init__() +# layout = QHBoxLayout(self) +# layout.setContentsMargins(10, 0, 0, 0) +# layout.setSpacing(0) +# self.label = QLabel(label) +# self.label.setFixedWidth(150) +# layout.addWidget(self.label) +# self.value = QLabel('-') +# self.value.setFixedWidth(160) +# layout.addWidget(self.value) +# self.unit = unit +# self.highlight = highlight +# if highlight: +# font = QFont() +# font.setBold(True) +# font.setPointSize(14) +# self.label.setFont(font) +# self.value.setFont(font) + +# def set_text(self, text): +# if self.unit is not None: +# text = text + ' ' + self.unit +# self.value.setText(text) + +class NumberIndicator(QWidget): + def __init__(self, label, unit=None, highlight=False, decimals=3): super().__init__() layout = QHBoxLayout(self) layout.setContentsMargins(10, 0, 0, 0) @@ -23,22 +49,29 @@ class Indicator(QWidget): self.label = QLabel(label) self.label.setFixedWidth(150) layout.addWidget(self.label) - self.value = QLabel('-') - self.value.setFixedWidth(160) - layout.addWidget(self.value) + self.val = QLabel('-') + self.val.setFixedWidth(160) + layout.addWidget(self.val) self.unit = unit self.highlight = highlight + self.decimals = decimals + self.number = 0 if highlight: font = QFont() font.setBold(True) font.setPointSize(14) self.label.setFont(font) - self.value.setFont(font) + self.val.setFont(font) - def set_text(self, text): + def value(self) -> float: + return self.number + + def setValue(self, number): + self.number = number + text = f'{number:.{self.decimals}f}' if self.unit is not None: text = text + ' ' + self.unit - self.value.setText(text) + self.val.setText(text) class InputTextField(QWidget): def __init__(self, topic, label): @@ -50,21 +83,24 @@ class InputTextField(QWidget): self.label = QLabel(label) self.label.setFixedWidth(150) layout.addWidget(self.label) - self.value = QLineEdit() - self.value.setPlaceholderText('0') - self.value.setFixedWidth(160) - layout.addWidget(self.value) + self.val = QLineEdit() + self.val.setPlaceholderText('0') + self.val.setFixedWidth(160) + layout.addWidget(self.val) def set_text(self, text): - self.value.setText(text) + self.val.setText(text) def has_focus(self) -> bool: - return self.value.hasFocus() + return self.val.hasFocus() + + def value(self) -> float: + return self.val.val() def set_on_return(self, func): """Connect a function to the Enter/Return key press.""" - self.value.returnPressed.connect( - partial(func, self.value, self.topic, lambda: self.value.text()) + self.val.returnPressed.connect( + partial(func, self.val, self.topic, lambda: self.val.text()) ) class InputNumberField(QWidget): @@ -76,87 +112,90 @@ class InputNumberField(QWidget): self.label = QLabel(label) self.label.setFixedWidth(150) layout.addWidget(self.label) - self.value = QDoubleSpinBox() - self.value.setValue(init) - self.value.setRange(ll, hl) - self.value.setDecimals(decimals) - self.value.setSingleStep(single_step) - self.value.setFixedWidth(160) - layout.addWidget(self.value) + self.val = QDoubleSpinBox() + self.val.setValue(init) + self.val.setRange(ll, hl) + self.val.setDecimals(decimals) + self.val.setSingleStep(single_step) + self.val.setFixedWidth(160) + layout.addWidget(self.val) def set_number(self, number): - self.value.setValue(number) + self.val.setValue(number) def has_focus(self) -> bool: - return self.value.hasFocus() + return self.val.hasFocus() + + def value(self) -> float: + return self.val.value() - def set_on_return(self, func): + def value_changed_connect(self, func): """Connect a function to the Enter/Return key press.""" - self.value.editingFinished.connect( - partial(func, self.value, lambda: self.value.text()) + self.val.valueChanged.connect( + partial(func, self.val, lambda: self.val.value()) ) -class IPAdressInputField(QWidget): - def __init__(self, topic, label): - super().__init__() - self.topic = topic - layout = QHBoxLayout(self) - layout.setContentsMargins(10, 0, 0, 0) - layout.setSpacing(0) - self.label = QLabel(label) - self.label.setFixedWidth(150) - layout.addWidget(self.label) - self.oct0 = QLineEdit() - self.oct0.setPlaceholderText('0') - self.oct0.setFixedWidth(30) - layout.addWidget(self.oct0) - separator1 = QLabel('.') - layout.addWidget(separator1) - self.oct1 = QLineEdit() - self.oct1.setPlaceholderText('0') - self.oct1.setFixedWidth(30) - layout.addWidget(self.oct1) - separator2 = QLabel('.') - layout.addWidget(separator2) - self.oct2 = QLineEdit() - self.oct2.setPlaceholderText('0') - self.oct2.setFixedWidth(30) - layout.addWidget(self.oct2) - separator3 = QLabel('.') - layout.addWidget(separator3) - self.oct3 = QLineEdit() - self.oct3.setPlaceholderText('0') - self.oct3.setFixedWidth(30) - layout.addWidget(self.oct3) +# class IPAdressInputField(QWidget): +# def __init__(self, topic, label): +# super().__init__() +# self.topic = topic +# layout = QHBoxLayout(self) +# layout.setContentsMargins(10, 0, 0, 0) +# layout.setSpacing(0) +# self.label = QLabel(label) +# self.label.setFixedWidth(150) +# layout.addWidget(self.label) +# self.oct0 = QLineEdit() +# self.oct0.setPlaceholderText('0') +# self.oct0.setFixedWidth(30) +# layout.addWidget(self.oct0) +# separator1 = QLabel('.') +# layout.addWidget(separator1) +# self.oct1 = QLineEdit() +# self.oct1.setPlaceholderText('0') +# self.oct1.setFixedWidth(30) +# layout.addWidget(self.oct1) +# separator2 = QLabel('.') +# layout.addWidget(separator2) +# self.oct2 = QLineEdit() +# self.oct2.setPlaceholderText('0') +# self.oct2.setFixedWidth(30) +# layout.addWidget(self.oct2) +# separator3 = QLabel('.') +# layout.addWidget(separator3) +# self.oct3 = QLineEdit() +# self.oct3.setPlaceholderText('0') +# self.oct3.setFixedWidth(30) +# layout.addWidget(self.oct3) - self.oct0.editingFinished.connect(partial(self.check_octet, self.oct0)) - self.oct1.editingFinished.connect(partial(self.check_octet, self.oct1)) - self.oct2.editingFinished.connect(partial(self.check_octet, self.oct2)) - self.oct3.editingFinished.connect(partial(self.check_octet, self.oct3)) +# self.oct0.editingFinished.connect(partial(self.check_octet, self.oct0)) +# self.oct1.editingFinished.connect(partial(self.check_octet, self.oct1)) +# self.oct2.editingFinished.connect(partial(self.check_octet, self.oct2)) +# self.oct3.editingFinished.connect(partial(self.check_octet, self.oct3)) - def check_octet(self, octet): - if octet.text().isnumeric(): - if int(octet.text()) < 0: - octet.setText('0') - if int(octet.text()) > 254: - octet.setText('254') - else: - octet.setText('') +# def check_octet(self, octet): +# if octet.text().isnumeric(): +# if int(octet.text()) < 0: +# octet.setText('0') +# if int(octet.text()) > 254: +# octet.setText('254') +# else: +# octet.setText('') - def get_ip(self): - return f'{self.oct0.text()}.{self.oct1.text()}.{self.oct2.text()}.{self.oct3.text()}' +# def get_ip(self): +# return f'{self.oct0.text()}.{self.oct1.text()}.{self.oct2.text()}.{self.oct3.text()}' - def set_ip(self, ip): - octets = ip.split('.') - if len(octets) == 4 and all(octet.isnumeric() for octet in octets): - if all(int(octet) > 0 and int(octet) < 254 for octet in octets): - self.oct0.setText(octets[0]) - self.oct1.setText(octets[1]) - self.oct2.setText(octets[2]) - self.oct3.setText(octets[3]) +# def set_ip(self, ip): +# octets = ip.split('.') +# if len(octets) == 4 and all(octet.isnumeric() for octet in octets): +# if all(int(octet) > 0 and int(octet) < 254 for octet in octets): +# self.oct0.setText(octets[0]) +# self.oct1.setText(octets[1]) +# self.oct2.setText(octets[2]) +# self.oct3.setText(octets[3]) class ComboBox(QWidget): - def __init__(self, enums, label): + def __init__(self, label, enums): super().__init__() layout = QHBoxLayout(self) layout.setContentsMargins(10, 0, 0, 0) @@ -173,101 +212,107 @@ class ComboBox(QWidget): def set_current_text(self, text): self.value.setCurrentText(text) + def currentText(self) -> str: + return self.value.currentText() + def has_focus(self) -> bool: return QApplication.focusWidget() is self.value.view() - def set_on_change(self, func, reset_plot=False): + def activated_connect(self, func): """Connect a function to the Enter/Return key press.""" self.value.activated.connect( - partial(func, self.value, lambda: self.value.currentText(), reset_plot) + partial(func, self.value, lambda: self.value.currentText()) ) -class LED(QWidget): - def __init__(self, states, colors, label): - super().__init__() - self.states = states - self.colors = colors - layout = QHBoxLayout(self) - layout.setContentsMargins(10, 0, 0, 0) - layout.setSpacing(0) - self.label = QLabel(label) - self.label.setFixedWidth(150) - layout.addWidget(self.label) - self.led = QLabel() - self.led.setFixedWidth(160) - layout.addWidget(self.led) + def setDisabled(self, disable): + self.value.setDisabled(disable) - def apply_color(self, val): - color = self.colors[self.states.index(val)] - self.led.setStyleSheet(f"background-color: {color}; border: 1px solid black;") +# class LED(QWidget): +# def __init__(self, states, colors, label): +# super().__init__() +# self.states = states +# self.colors = colors +# layout = QHBoxLayout(self) +# layout.setContentsMargins(10, 0, 0, 0) +# layout.setSpacing(0) +# self.label = QLabel(label) +# self.label.setFixedWidth(150) +# layout.addWidget(self.label) +# self.led = QLabel() +# self.led.setFixedWidth(160) +# layout.addWidget(self.led) -class StartStop(QWidget): - def __init__(self, label, label_buttons=['Start', 'Stop']): - super().__init__() - layout = QHBoxLayout(self) - layout.setContentsMargins(10, 0, 0, 0) - layout.setSpacing(0) - self.label = QLabel(label) - self.label.setFixedWidth(150) - layout.addWidget(self.label) - self.start = QPushButton(label_buttons[0]) - self.start.setStyleSheet("color: black; background-color: green;") - self.start.setFixedWidth(80) - self.stop = QPushButton(label_buttons[1]) - self.stop.setStyleSheet("color: black; background-color: firebrick;") - self.stop.setFixedWidth(80) - layout.addWidget(self.start) - layout.addWidget(self.stop) +# def apply_color(self, val): +# color = self.colors[self.states.index(val)] +# self.led.setStyleSheet(f"background-color: {color}; border: 1px solid black;") - def set_on_start(self, func): - """Connect a function to the start button press.""" - self.start.clicked.connect(func) +# class StartStop(QWidget): +# def __init__(self, label, label_buttons=['Start', 'Stop']): +# super().__init__() +# layout = QHBoxLayout(self) +# layout.setContentsMargins(10, 0, 0, 0) +# layout.setSpacing(0) +# self.label = QLabel(label) +# self.label.setFixedWidth(150) +# layout.addWidget(self.label) +# self.start = QPushButton(label_buttons[0]) +# self.start.setStyleSheet("color: black; background-color: green;") +# self.start.setFixedWidth(80) +# self.stop = QPushButton(label_buttons[1]) +# self.stop.setStyleSheet("color: black; background-color: firebrick;") +# self.stop.setFixedWidth(80) +# layout.addWidget(self.start) +# layout.addWidget(self.stop) - def set_on_stop(self, func): - """Connect a function to the stop button press.""" - self.stop.clicked.connect(func) +# def set_on_start(self, func): +# """Connect a function to the start button press.""" +# self.start.clicked.connect(func) - def enable_start(self): - self.start.setEnabled(True) - self.start.setStyleSheet("color: black; background-color: green;") +# def set_on_stop(self, func): +# """Connect a function to the stop button press.""" +# self.stop.clicked.connect(func) - def enable_stop(self): - self.stop.setEnabled(True) - self.stop.setStyleSheet("color: black; background-color: firebrick;") +# def enable_start(self): +# self.start.setEnabled(True) +# self.start.setStyleSheet("color: black; background-color: green;") - def disable_start(self): - self.start.setEnabled(False) - self.start.setStyleSheet("color: black; background-color: grey;") +# def enable_stop(self): +# self.stop.setEnabled(True) +# self.stop.setStyleSheet("color: black; background-color: firebrick;") - def disable_stop(self): - self.stop.setEnabled(False) - self.stop.setStyleSheet("color: black; background-color: grey;") +# def disable_start(self): +# self.start.setEnabled(False) +# self.start.setStyleSheet("color: black; background-color: grey;") -class Button(QWidget): - def __init__(self, label, label_button): - super().__init__() - layout = QHBoxLayout(self) - layout.setContentsMargins(10, 0, 0, 0) - layout.setSpacing(0) - self.label = QLabel(label) - self.label.setFixedWidth(150) - layout.addWidget(self.label) - self.button = QPushButton(label_button) - self.button.setStyleSheet("color: black; background-color: dodgerblue;") - self.button.setFixedWidth(160) - layout.addWidget(self.button) +# def disable_stop(self): +# self.stop.setEnabled(False) +# self.stop.setStyleSheet("color: black; background-color: grey;") - def set_on_press(self, func): - """Connect a function to the button press.""" - self.button.clicked.connect(func) +# class Button(QWidget): +# def __init__(self, label, label_button): +# super().__init__() +# layout = QHBoxLayout(self) +# layout.setContentsMargins(10, 0, 0, 0) +# layout.setSpacing(0) +# self.label = QLabel(label) +# self.label.setFixedWidth(150) +# layout.addWidget(self.label) +# self.button = QPushButton(label_button) +# self.button.setStyleSheet("color: black; background-color: dodgerblue;") +# self.button.setFixedWidth(160) +# layout.addWidget(self.button) - def enable_button(self): - self.button.setEnabled(True) - self.button.setStyleSheet("color: black; background-color: dodgerblue;") +# def set_on_press(self, func): +# """Connect a function to the button press.""" +# self.button.clicked.connect(func) - def disable_button(self): - self.button.setEnabled(False) - self.button.setStyleSheet("color: black; background-color: grey;") +# def enable_button(self): +# self.button.setEnabled(True) +# self.button.setStyleSheet("color: black; background-color: dodgerblue;") - def set_button_text(self, text): - self.button.setText(text) +# def disable_button(self): +# self.button.setEnabled(False) +# self.button.setStyleSheet("color: black; background-color: grey;") + +# def set_button_text(self, text): +# self.button.setText(text) From 282756288f806d7a911f16e5c7f67aff85c39b65 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 30 Apr 2026 08:11:09 +0200 Subject: [PATCH 29/54] Adding xrt library for digital twin --- pyproject.toml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f34c291..e5873cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,15 @@ classifiers = [ "Programming Language :: Python :: 3", "Topic :: Scientific/Engineering", ] -dependencies = ["numpy", "scipy", "bec_lib", "h5py", "ophyd_devices", "opencv-python==4.11.0.86"] +dependencies = [ + "numpy", + "scipy", + "bec_lib", + "h5py", + "ophyd_devices", + "opencv-python==4.11.0.86", + "xrt", +] [project.optional-dependencies] dev = [ From 274bb9154c4ed0cb059c3474bad059a486de1264 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 30 Apr 2026 08:11:33 +0200 Subject: [PATCH 30/54] wip: digital twin --- .../digital_twin/calculate_positions.py | 55 ++-- .../widgets/digital_twin/digital_twin.py | 290 ++++++++---------- .../widgets/digital_twin/x01da_parameters.py | 22 +- debye_bec/bec_widgets/widgets/qt_widgets.py | 14 +- 4 files changed, 170 insertions(+), 211 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py b/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py index d155243..6edfd10 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py @@ -7,7 +7,7 @@ import debye_bec.bec_widgets.widgets.digital_twin.x01da_parameters as bl logger = bec_logger.logger -def calculate_positions(cfg): +def calc_positions(cfg): pos = {} @@ -56,7 +56,7 @@ def calculate_positions(cfg): # See raytracing script or here: bragg = np.asin(rm.ch / (2.*cfg['dSpacing']*cfg['energyCCM'])) - aCrystal.get_dtheta_symmetric_Bragg(cfg['energyCCM']) if cfg['mo_mode'] == 'Monochromatic': # Add 2x CM pitch to the bragg angle - bragg = ((2 * cfg['cm_pitch']) + cfg['mo_bragg'][1]) / np.pi * 180 + bragg = ((2 * cfg['cm_pitch']) + cfg['mo_bragg']) / np.pi * 180 elif cfg['mo_mode'] == 'Pinkbeam': # Align xtal surfaces parallel to beam bragg = (2 * cfg['cm_pitch']) / np.pi * 180 @@ -65,12 +65,12 @@ def calculate_positions(cfg): pos['mo1_bragg_angle'] = {'value': bragg} # Bragg angle in deg # TRY, Height - l = bl.mo1.xtalGap[0]/np.sin(cfg['mo_bragg'][1]) - yhor = l*np.cos(2.*(cfg['mo_bragg'][1]+cfg['cm_pitch'])) + l = bl.mo1.xtalGap[0]/np.sin(cfg['mo_bragg']) + yhor = l*np.cos(2.*(cfg['mo_bragg']+cfg['cm_pitch'])) yver = yhor*np.tan(2.*cfg['cm_pitch']) if cfg['mo_mode'] == 'Monochromatic': - beamOffsetCCM = l*np.sin(2.*(cfg['mo_bragg'][1]+cfg['cm_pitch']))-yver # Resultat ist korrekt! + beamOffsetCCM = l*np.sin(2.*(cfg['mo_bragg']+cfg['cm_pitch']))-yver # Resultat ist korrekt! elif cfg['mo_mode'] == 'Pinkbeam': beamOffsetCCM = 0 else: @@ -84,22 +84,22 @@ def calculate_positions(cfg): # calculate height of center of first crystal surface f = bl.mo1.rotOffset # rotation offset, mm - logger.info(f'f = {f}') + # logger.info(f'f = {f}') d = bl.mo1.heightOffset # xtal height offset, mm - logger.info(f'd = {d}') - c = d*csc(cfg['mo_bragg'][1])-f*cot(cfg['mo_bragg'][1]) - logger.info(f'c = {c}') + # logger.info(f'd = {d}') + c = d*csc(cfg['mo_bragg'])-f*cot(cfg['mo_bragg']) + # logger.info(f'c = {c}') # Calculate height of center of rotation - b = np.sqrt(d**2*csc(cfg['mo_bragg'][1])**2-2*d*f*cot(cfg['mo_bragg'][1])*csc(cfg['mo_bragg'][1])+f**2*cot(cfg['mo_bragg'][1])**2+f**2) - logger.info(f'b = {b}') - h = np.cos(np.pi/2-np.arctan(f/c)-cfg['mo_bragg'][1]-2*cfg['cm_pitch'])*b - logger.info(f'h = {h}') + b = np.sqrt(d**2*csc(cfg['mo_bragg'])**2-2*d*f*cot(cfg['mo_bragg'])*csc(cfg['mo_bragg'])+f**2*cot(cfg['mo_bragg'])**2+f**2) + # logger.info(f'b = {b}') + h = np.cos(np.pi/2-np.arctan(f/c)-cfg['mo_bragg']-2*cfg['cm_pitch'])*b + # logger.info(f'h = {h}') h2 = ((bl.mo1.center[1] - bl.cm.center[1])-np.sqrt(b**2-h**2))*np.tan(2*cfg['cm_pitch']) - logger.info(f'mo1 = {bl.mo1.center[1]}') - logger.info(f'cm = {bl.cm.center[1]}') - logger.info(f'pitch = {cfg["cm_pitch"]}') - logger.info(f'h2 = {h2}') + # logger.info(f'mo1 = {bl.mo1.center[1]}') + # logger.info(f'cm = {bl.cm.center[1]}') + # logger.info(f'pitch = {cfg["cm_pitch"]}') + # logger.info(f'h2 = {h2}') #TODO Mono height not exactly the same as in raytracing heightCCM1real = h + h2 # per design, the height should not change if the pitch of the CM is not changed! # heightCCM1real = heightCCM1real - 30 # Zero position of stage is at 1430 mm from ground. @@ -112,12 +112,15 @@ def calculate_positions(cfg): pos['mo1_try'] = {'value': heightCCM1real} # TRX, Crystal selection - try: - xtal = cfg['mo_xtal'].translate(str.maketrans('', '', '()')) # Remove brackets from xtal name to conform with parameters - index = bl.mo1.xtal.index(xtal) - except: - raise ValueError(f"Requested xtal {xtal} not found in parameters!") - pos['mo1_trx'] = {'value': bl.mo1.xtalOffsetX[index]} + if cfg['mo_mode'] == 'Monochromatic': + try: + xtal = cfg['mo_xtal'].translate(str.maketrans('', '', '()')) # Remove brackets from xtal name to conform with parameters + index = bl.mo1.xtal.index(xtal) + except: + raise ValueError(f"Requested xtal {xtal} not found in parameters!") + pos['mo1_trx'] = {'value': bl.mo1.xtalOffsetX[index]} + else: + pos['mo1_trx'] = {'value': 0} #TODO move to mono, calc for beam Z-movement between crystal surfaces @@ -131,9 +134,9 @@ def calculate_positions(cfg): ## Beam Monitor 1 d = bl.opBM1.center[1] - bl.cm.center[1] - dz - logger.info(f'distance: {d}') - logger.info(f'cm pitch: {cfg["cm_pitch"]}') - logger.info(f'mono offset: {beamOffsetCCM}') + # logger.info(f'distance: {d}') + # logger.info(f'cm pitch: {cfg["cm_pitch"]}') + # logger.info(f'mono offset: {beamOffsetCCM}') bm1_beam_height = d * np.tan(2 * cfg['cm_pitch']) + beamOffsetCCM pos['bm1_try'] = {'value': bm1_beam_height} diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 33b1f18..01a3796 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -1,4 +1,5 @@ import sys +import os import datetime import numpy as np from bec_lib import bec_logger @@ -17,7 +18,10 @@ from bec_widgets.utils.error_popups import SafeSlot from debye_bec.bec_widgets.widgets.qt_widgets import InputNumberField, ComboBox, Group, NumberIndicator -from debye_bec.bec_widgets.widgets.digital_twin.calculate_positions import calculate_positions +from debye_bec.bec_widgets.widgets.digital_twin.calculate_positions import calc_positions + +from xrt.backends.raycing.physconsts import CHeVcm, AVOGADRO +import debye_bec.bec_widgets.widgets.digital_twin.x01da_parameters as bl logger = bec_logger.logger @@ -45,32 +49,44 @@ class DigitalTwin(BECWidget, QWidget): self.plot_widget = PlotWidget(title='Plot title', chart_data = []) self.input = InputPanel() - self.positions_panel = PositionsPanel() + self.positions = PositionsPanel() self.root_layout.addWidget(self.plot_widget, stretch=3) self.root_layout.addWidget(self.input, stretch=1, alignment=Qt.AlignTop) - self.root_layout.addWidget(self.positions_panel, stretch=1, alignment=Qt.AlignTop) + self.root_layout.addWidget(self.positions, stretch=1, alignment=Qt.AlignTop) self.setLayout(self.root_layout) self.setWindowTitle("Digital Twin") self.resize(600, 500) - self.input.energy.value_changed_connect(self.calculate_positions) - self.input.sldi_hacc.value_changed_connect(self.calculate_positions) - self.input.sldi_vacc.value_changed_connect(self.calculate_positions) - self.input.cm_stripe.activated_connect(self.calculate_positions) - self.input.cm_pitch.value_changed_connect(self.calculate_positions) - self.input.mo1_mode.activated_connect(self.calculate_positions) - self.input.mo1_xtal.activated_connect(self.calculate_positions) - self.input.fm_stripe.activated_connect(self.calculate_positions) - self.input.fm_pitch.value_changed_connect(self.calculate_positions) - self.input.smpl.value_changed_connect(self.calculate_positions) + self.input.energy.value_changed_connect(self.calc_bragg_angle) + self.input.sldi_hacc.value_changed_connect(self.calc_positions) + self.input.sldi_vacc.value_changed_connect(self.calc_positions) + self.input.cm_stripe.activated_connect(self.calc_positions) + self.input.cm_pitch.value_changed_connect(self.calc_positions) + self.input.mo1_mode.activated_connect(self.calc_positions) + self.input.fm_stripe.activated_connect(self.calc_positions) + self.input.fm_pitch.value_changed_connect(self.calc_positions) + self.input.smpl.value_changed_connect(self.calc_positions) + + self.input.energy.value_changed_connect(self.calc_crit_angle) + self.input.cm_stripe.activated_connect(self.calc_crit_angle) + + self.input.mo1_xtal.activated_connect(self.calc_bragg_angle) self.input.mo1_mode.activated_connect(self.update_mono_mode) + self.input.fm_stripe.activated_connect(self.calc_ideal_fm_pitch) + self.input.smpl.value_changed_connect(self.calc_ideal_fm_pitch) + + self.bragg_angle = 0 + self.calc_bragg_angle() + self.calc_ideal_fm_pitch() + self.calc_crit_angle() + @SafeSlot() - def calculate_positions(self, field, qt_obj, number, *args): - logger.info(f'Got field {field} and number {qt_obj} and number {number} and args {args}') + def calc_positions(self, *args): + # logger.info(f'Got field {field} and number {qt_obj} and number {number} and args {args}') config = { # Config in SI units! 'energy' : self.input.energy.value(), @@ -80,7 +96,7 @@ class DigitalTwin(BECWidget, QWidget): 'cm_stripe' : self.input.cm_stripe.currentText(), 'mo_mode' : self.input.mo1_mode.currentText(), 'mo_xtal' : self.input.mo1_xtal.currentText(), - 'mo_bragg' : [12.725, 12.725], + 'mo_bragg' : self.bragg_angle, 'fm_pitch' : -self.input.fm_pitch.value() * 1e-3, 'fm_stripe' : self.input.fm_stripe.currentText(), 'fm_gain_height' : 1, @@ -88,10 +104,53 @@ class DigitalTwin(BECWidget, QWidget): } logger.info(f'Config created: {config}') + out = calc_positions(config) + logger.info(f'Got positions: {out}') - positions = calculate_positions(config) + self.positions.sldi_gapx.setValue(out['sldi_gapx']['value']) + self.positions.sldi_gapy.setValue(out['sldi_gapy']['value']) + self.positions.cm_trx.setValue(out['cm_trx']['value']) + self.positions.cm_try.setValue(out['cm_try']['value']) + self.positions.cm_bnd.setValue(out['cm_bnd_radius']['value']) + self.positions.cm_rotx.setValue(out['cm_rotx']['value']) + self.positions.mo1_bragg_angle.setValue(out['mo1_bragg_angle']['value']) + self.positions.mo1_trx.setValue(out['mo1_trx']['value']) + self.positions.mo1_try.setValue(out['mo1_try']['value']) + self.positions.sl1_centery.setValue(out['sl1_centery']['value']) + self.positions.bm1_try.setValue(out['bm1_try']['value']) + self.positions.fm_trx.setValue(out['fm_trx']['value']) + self.positions.fm_try.setValue(out['fm_try']['value']) + self.positions.fm_bnd.setValue(out['fm_bnd_radius']['value']) + self.positions.fm_rotx.setValue(out['fm_rotx']['value']) + self.positions.sl2_centery.setValue(out['sl2_centery']['value']) + self.positions.bm2_try.setValue(out['bm2_try']['value']) + self.positions.ot_try.setValue(out['ot_try']['value']) + self.positions.ot_rotx.setValue(out['ot_rotx']['value']) + self.positions.ot_es1_trz.setValue(out['ot_es1_trz']['value']) - logger.info(f'Got positions: {positions}') + @SafeSlot() + def calc_bragg_angle(self, *args): + """ + Calculates bragg angle in rad + """ + xtal = self.input.mo1_xtal.currentText() + if xtal in 'Si(111)': + d_spacing = self.dev.mo1_bragg.crystal.d_spacing_si111.get() + elif xtal in 'Si(311)': + d_spacing = self.dev.mo1_bragg.crystal.d_spacing_si311.get() + else: + raise Exception(f'Invalid xtal selection: {xtal}') + + H = 6.62606957E-34 + E = 1.602176634E-19 + C = 299792458 + + wl = C * H / (E * self.input.energy.value()) + val = wl / (2 * d_spacing * 1e-10) + self.bragg_angle = 0 + if val > -1 and val < 1: + self.bragg_angle = np.asin(val) + self.calc_positions() @SafeSlot() def update_mono_mode(self, *args): @@ -101,141 +160,36 @@ class DigitalTwin(BECWidget, QWidget): else: self.input.mo1_xtal.setDisabled(True) - # self.init_ui() - # self._recalculate() # populate outputs on startup - - # Timer: update plot every 1 second - # self._timer = QTimer(self) - # self._timer.setInterval(1000) - # self._timer.timeout.connect(self._tick) - # self._timer.start() - - # ------------------------------------------------------------------ UI --- - - # def init_ui(self): - - # self.spin_a = InputNumberField('Acceptance 1') - # self.spin_b = InputNumberField('Acceptance 2') - # self.input_group = Group( - # 'Inputs', - # [ - # self.spin_a, - # self.spin_b, - # ] - # ) - # self.root_layout.addWidget(self.input_group) - # self.root_layout.addStretch() - - - # root = QVBoxLayout(self) - - # # --- Inputs --- - # input_group = QGroupBox("Inputs") - # input_layout = QHBoxLayout(input_group) - - # self._spin_a = QLineEdit() - # self._spin_a.setPlaceholderText('0') - # self._spin_a.setText('0') - # # self._spin_a.setRange(-1e6, 1e6) - # # self._spin_a.setDecimals(3) - # # self._spin_a.setValue(1.0) - # # self._spin_a.setSingleStep(0.1) - - # self._spin_b = QLineEdit() - # self._spin_b.setPlaceholderText('0') - # self._spin_b.setText('0') - # # self._spin_b.setRange(-1e6, 1e6) - # # self._spin_b.setDecimals(3) - # # self._spin_b.setValue(2.0) - # # self._spin_b.setSingleStep(0.1) - - # self._spin_c = QLineEdit() - # self._spin_c.setPlaceholderText('0') - # self._spin_c.setText('10') - - # input_layout.addWidget(QLabel("A:")) - # input_layout.addWidget(self._spin_a) - # input_layout.addWidget(QLabel("B:")) - # input_layout.addWidget(self._spin_b) - # input_layout.addWidget(QLabel("C:")) - # input_layout.addWidget(self._spin_c) - # root.addWidget(input_group) - - # # --- Outputs --- - # output_group = QGroupBox("Outputs") - # output_layout = QHBoxLayout(output_group) - - # self._label_sum = QLabel("Sum: —") - # self._label_product = QLabel("Product: —") - # output_layout.addWidget(self._label_sum) - # output_layout.addWidget(self._label_product) - # root.addWidget(output_group) - - # # --- Plot --- - # plot_group = QGroupBox("Live History (updates every 1 s)") - # plot_layout = QVBoxLayout(plot_group) - - # self._plot_widget = pg.PlotWidget() - # self._plot_widget.setBackground("w") - # self._plot_widget.addLegend() - # self._plot_widget.setLabel("left", "Value") - # self._plot_widget.setLabel("bottom", "Tick") - - # self._curve_sum = self._plot_widget.plot( - # pen=pg.mkPen("b", width=2), name="Sum" - # ) - # self._curve_product = self._plot_widget.plot( - # pen=pg.mkPen("r", width=2), name="Product" - # ) - # plot_layout.addWidget(self._plot_widget) - # plot_group.setLayout(plot_layout) - # root.addWidget(plot_group) - - # self.setLayout(root) - # self.setWindowTitle("BEC Calculator Widget") - # self.resize(600, 500) - - # # Connect inputs → recalculate - # self._spin_a.editingFinished .connect(self._recalculate) - # self._spin_b.editingFinished .connect(self._recalculate) - - # ---------------------------------------------------------- Logic --- - - # @SafeSlot() - # def _recalculate(self): - # # logger.info(var) - # a = float(self._spin_a.text()) - # b = float(self._spin_b.text()) - # s = a + b - # p = a * b - # self._label_sum.setText(f"Sum: {s:.4f}") - # self._label_product.setText(f"Product: {p:.4f}") - # self._current_sum = s - # self._current_product = p - - # @SafeSlot() - # def _tick(self): - # """Called every second: record current outputs and refresh plot.""" - # self._history.append((self._t, self._current_sum, self._current_product)) - # self._t += 1 - - # ticks = [h[0] for h in self._history] - # sums = [h[1] for h in self._history] - # products = [h[2] for h in self._history] - - # self._curve_sum.setData(ticks, sums) - # self._curve_product.setData(ticks, products) - - # # --------------------------------------------------- RPC interface --- - - # def set_a(self, value: float): - # """Set input A remotely from the BEC CLI.""" - # self._spin_a.setValue(value) - - # def set_b(self, value: float): - # """Set input B remotely from the BEC CLI.""" - # self._spin_b.setValue(value) + @SafeSlot() + def calc_ideal_fm_pitch(self, *args): + p = bl.fm.center[1] # posFM + q = self.input.smpl.value() - bl.fm.center[1] # dist posFM to posEX + f = (p * q) / (p + q) # focal length + pitch = 0 + if 'Rh' in self.input.fm_stripe.currentText(): + pitch = np.arcsin(bl.fm.r[0]/(2*f))# ideal pitch for FM + if 'Pt' in self.input.fm_stripe.currentText(): + pitch = np.arcsin(bl.fm.r[1]/(2*f)) # ideal pitch for FM + self.input.fm_pitch_ideal.setValue(-pitch * 1e3) + @SafeSlot() + def calc_crit_angle(self, *args): + stripe = self.input.cm_stripe.currentText() + # Config Mirror + if stripe in 'Si': + stripe = bl.stripeSi + elif stripe in 'Pt': + stripe = bl.stripePt + elif stripe in 'Rh': + stripe = bl.stripeRh + else: + raise Exception(f'Stripe {stripe} not found in beamline parameters!') + w = CHeVcm/100/self.input.energy.value() # convert energy [eV] to wavelength [m] + # Calculate critical angle for mirror + f1 = stripe.elements[0].Z + np.real(stripe.elements[0].get_f1f2(self.input.energy.value())) + numberDensity = stripe.rho*1e3*AVOGADRO/(stripe.elements[0].mass/1e3) + criticalAngle = np.sqrt(numberDensity*2.8179e-15*w**2*f1/np.pi) + self.input.cm_pitch_critical.setValue(-criticalAngle * 1e3) class InputPanel(QWidget): """Right-side control panel: input field, indicator, send, recording.""" @@ -262,7 +216,7 @@ class InputPanel(QWidget): # Collimating mirror self.cm_stripe = ComboBox('Stripe', ['Si', 'Rh', 'Pt']) self.cm_pitch_critical = NumberIndicator('Critical Pitch', 'mrad', decimals=3) - self.cm_pitch = InputNumberField('Pitch [mrad]', init=-3, decimals=3, single_step=0.01, ll=-4.6, hl=-1.2) + self.cm_pitch = InputNumberField('Pitch [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-4.6, hl=-1.2) self.cm_ass_group = Group( 'Collimating Mirror', [ @@ -286,7 +240,7 @@ class InputPanel(QWidget): # Focusing Mirror self.fm_stripe = ComboBox('Stripe', ['Rh (toroid)', 'Rh (flat)', 'Pt (toroid)', 'Pt (flat)']) self.fm_pitch_ideal = NumberIndicator('Ideal Pitch', 'mrad', decimals=3) - self.fm_pitch = InputNumberField('Pitch [mrad]', init=-3, decimals=3, single_step=0.01, ll=-10, hl=2) + self.fm_pitch = InputNumberField('Pitch [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-10, hl=2) self.fm_ass_group = Group( 'Focusing Mirror', [ @@ -297,7 +251,7 @@ class InputPanel(QWidget): ) # Sample - self.smpl = InputNumberField('Sample Position [mm]') + self.smpl = InputNumberField('Sample Position [mm]', init=23511, decimals=0, single_step=100, ll=23000, hl=30000) # Assemble complete assitant group self.input_group = Group( @@ -337,8 +291,8 @@ class PositionsPanel(QWidget): # Collimating mirror self.cm_trx = NumberIndicator('TRX', 'mm', decimals=1) self.cm_try = NumberIndicator('TRY', 'mm', decimals=3) - self.cm_bnd = NumberIndicator('BENDER', 'mm', decimals=2) - self.cm_rotx = NumberIndicator('PITCH', 'mm', decimals=3) + self.cm_bnd = NumberIndicator('BENDER', 'km', decimals=2) + self.cm_rotx = NumberIndicator('PITCH', 'mrad', decimals=3) self.cm_pos_group = Group( 'Collimating Mirror', [ @@ -350,7 +304,7 @@ class PositionsPanel(QWidget): ) # Monochromator - self.mo1_bragg_angle = NumberIndicator('Bragg Angle', 'mm', decimals=3) + self.mo1_bragg_angle = NumberIndicator('Bragg Angle', 'deg', decimals=3) self.mo1_trx = NumberIndicator('TRX', 'mm', decimals=1) self.mo1_try = NumberIndicator('TRY', 'mm', decimals=3) self.mo1_pos_group = Group( @@ -383,8 +337,8 @@ class PositionsPanel(QWidget): # Focusing Mirror self.fm_trx = NumberIndicator('TRX', 'mm', decimals=1) self.fm_try = NumberIndicator('TRY', 'mm', decimals=3) - self.fm_bnd = NumberIndicator('BENDER', 'mm', decimals=2) - self.fm_rotx = NumberIndicator('PITCH', 'mm', decimals=3) + self.fm_bnd = NumberIndicator('BENDER', 'km', decimals=2) + self.fm_rotx = NumberIndicator('PITCH', 'mrad', decimals=3) self.fm_pos_group = Group( 'Focusing Mirror', [ @@ -414,15 +368,15 @@ class PositionsPanel(QWidget): ) # Optical Table - self.es_try = NumberIndicator('TRY', 'mm', decimals=3) - self.es_rotx = NumberIndicator('ROTX', 'mm', decimals=3) - self.es1_trz = NumberIndicator('ES1 TRZ', 'mm', decimals=0) - self.es_pos_group = Group( + self.ot_try = NumberIndicator('TRY', 'mm', decimals=0) + self.ot_rotx = NumberIndicator('ROTX', 'mrad', decimals=3) + self.ot_es1_trz = NumberIndicator('ES1 TRZ', 'mm', decimals=0) + self.ot_pos_group = Group( 'Optical Table', [ - self.es_try, - self.es_rotx, - self.es1_trz, + self.ot_try, + self.ot_rotx, + self.ot_es1_trz, ] ) @@ -438,7 +392,7 @@ class PositionsPanel(QWidget): self.fm_pos_group, self.sl2_pos_group, self.bm2_pos_group, - self.es_pos_group, + self.ot_pos_group, ] ) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py b/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py index 650e84f..1ddd98b 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py @@ -8,16 +8,18 @@ import os import numpy as np from collections import namedtuple -if os.environ.get("USE_XRT", "True").lower() in ("1", "true", "yes"): - import xrt.backends.raycing.materials as rm # type: ignore -else: - class _DummyClass: - def __init__(self, *args, **kwargs): - pass - class _DummyMaterials: - Material = _DummyClass - CrystalSi = _DummyClass - rm = _DummyMaterials() +import xrt.backends.raycing.materials as rm + +# if os.environ.get("USE_XRT", "True").lower() in ("1", "true", "yes"): +# import xrt.backends.raycing.materials as rm # type: ignore +# else: +# class _DummyClass: +# def __init__(self, *args, **kwargs): +# pass +# class _DummyMaterials: +# Material = _DummyClass +# CrystalSi = _DummyClass +# rm = _DummyMaterials() # XRT definitions filterBeryl = rm.Material('Be', rho=1.85, kind='plate') # pyright: ignore[reportArgumentType] diff --git a/debye_bec/bec_widgets/widgets/qt_widgets.py b/debye_bec/bec_widgets/widgets/qt_widgets.py index 82ba6da..14d0fdd 100644 --- a/debye_bec/bec_widgets/widgets/qt_widgets.py +++ b/debye_bec/bec_widgets/widgets/qt_widgets.py @@ -50,7 +50,7 @@ class NumberIndicator(QWidget): self.label.setFixedWidth(150) layout.addWidget(self.label) self.val = QLabel('-') - self.val.setFixedWidth(160) + # self.val.setFixedWidth(140) layout.addWidget(self.val) self.unit = unit self.highlight = highlight @@ -68,7 +68,7 @@ class NumberIndicator(QWidget): def setValue(self, number): self.number = number - text = f'{number:.{self.decimals}f}' + text = f'{number:.{int(self.decimals)}f}' if self.unit is not None: text = text + ' ' + self.unit self.val.setText(text) @@ -85,7 +85,7 @@ class InputTextField(QWidget): layout.addWidget(self.label) self.val = QLineEdit() self.val.setPlaceholderText('0') - self.val.setFixedWidth(160) + # self.val.setFixedWidth(140) layout.addWidget(self.val) def set_text(self, text): @@ -104,7 +104,7 @@ class InputTextField(QWidget): ) class InputNumberField(QWidget): - def __init__(self, label, init=0, decimals=1, single_step=0.1, ll=-1e6, hl=1e6): + def __init__(self, label, init=0.0, decimals=1, single_step=0.1, ll=-1e6, hl=1e6): super().__init__() layout = QHBoxLayout(self) layout.setContentsMargins(10, 0, 0, 0) @@ -113,11 +113,11 @@ class InputNumberField(QWidget): self.label.setFixedWidth(150) layout.addWidget(self.label) self.val = QDoubleSpinBox() - self.val.setValue(init) self.val.setRange(ll, hl) self.val.setDecimals(decimals) self.val.setSingleStep(single_step) - self.val.setFixedWidth(160) + self.val.setValue(init) + # self.val.setFixedWidth(140) layout.addWidget(self.val) def set_number(self, number): @@ -204,7 +204,7 @@ class ComboBox(QWidget): self.label.setFixedWidth(150) layout.addWidget(self.label) self.value = QComboBox() - self.value.setFixedWidth(160) + # self.value.setFixedWidth(140) for entry in enums: self.value.addItem(entry) layout.addWidget(self.value) From ce3f2312765f46df524df1e24cd151239deff339 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 30 Apr 2026 14:47:16 +0200 Subject: [PATCH 31/54] wip: digital twin --- .../widgets/digital_twin/digital_twin.py | 137 ++++++++++++------ .../widgets/digital_twin/x01da_parameters.py | 20 +++ debye_bec/bec_widgets/widgets/qt_widgets.py | 8 +- 3 files changed, 117 insertions(+), 48 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 01a3796..608014b 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -1,18 +1,19 @@ import sys -import os import datetime import numpy as np from bec_lib import bec_logger # pylint: disable=E0611 from qtpy.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QDoubleSpinBox, QGroupBox, QApplication, QLineEdit, QLayout + QApplication, QLayout ) # pylint: disable=E0611 -from qtpy.QtCore import QTimer, Qt -from qtpy.QtGui import QColor +from qtpy.QtCore import Qt +from qtpy.QtGui import QColor, QFont import pyqtgraph as pg +from xrt.backends.raycing.physconsts import CHeVcm, AVOGADRO + from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot @@ -20,7 +21,6 @@ from debye_bec.bec_widgets.widgets.qt_widgets import InputNumberField, ComboBox, from debye_bec.bec_widgets.widgets.digital_twin.calculate_positions import calc_positions -from xrt.backends.raycing.physconsts import CHeVcm, AVOGADRO import debye_bec.bec_widgets.widgets.digital_twin.x01da_parameters as bl logger = bec_logger.logger @@ -33,7 +33,6 @@ class DigitalTwin(BECWidget, QWidget): - A live plot that updates every second """ - USER_ACCESS = ["set_a", "set_b"] PLUGIN = True ICON_NAME = "lightbulb" @@ -41,23 +40,20 @@ class DigitalTwin(BECWidget, QWidget): super().__init__(parent=parent, theme_update=True, *arg, **kwargs) self.get_bec_shortcuts() - self._history = [] # stores (sum, product) over time - self._t = 0 # tick counter - central = QWidget() self.root_layout = QHBoxLayout(central) - self.plot_widget = PlotWidget(title='Plot title', chart_data = []) self.input = InputPanel() + self.plot_widget = PlotWidget() self.positions = PositionsPanel() - self.root_layout.addWidget(self.plot_widget, stretch=3) - self.root_layout.addWidget(self.input, stretch=1, alignment=Qt.AlignTop) - self.root_layout.addWidget(self.positions, stretch=1, alignment=Qt.AlignTop) + self.root_layout.addWidget(self.input, stretch=1, alignment=Qt.AlignTop) # type: ignore + self.root_layout.addWidget(self.plot_widget, stretch=1, alignment=Qt.AlignTop) # type: ignore + self.root_layout.addWidget(self.positions, stretch=1, alignment=Qt.AlignTop) # type: ignore self.setLayout(self.root_layout) self.setWindowTitle("Digital Twin") - self.resize(600, 500) + # self.resize(1500, 800) self.input.energy.value_changed_connect(self.calc_bragg_angle) self.input.sldi_hacc.value_changed_connect(self.calc_positions) @@ -197,7 +193,7 @@ class InputPanel(QWidget): def __init__(self, parent=None): super().__init__(parent) self._layout = QVBoxLayout(self) - self._layout.setSizeConstraint(QLayout.SetFixedSize) + self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore # Energy self.energy = InputNumberField('Energy [keV]', init=8979, decimals=0, single_step=100, ll=4000, hl=65000) @@ -275,7 +271,7 @@ class PositionsPanel(QWidget): def __init__(self, parent=None): super().__init__(parent) self._layout = QVBoxLayout(self) - self._layout.setSizeConstraint(QLayout.SetFixedSize) + self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore # FE Slits self.sldi_gapx = NumberIndicator('GAPX', 'mm', decimals=3) @@ -402,25 +398,31 @@ class PositionsPanel(QWidget): class PlotWidget(QWidget): """Plot widget with two curves and legend.""" - def __init__(self, title: str = "Title", chart_data = [], max_points=2000, parent=None): + def __init__(self, parent=None): super().__init__(parent) - self.chart_data = chart_data - self.max_points = max_points - self._layout = QVBoxLayout(self) - self._title = QLabel(f"

{title}

") - self._layout.addWidget(self._title) + # self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore - self.plot_widget = pg.PlotWidget(axisItems={'bottom': TimeAxis(orientation='bottom')}) + self.plot_widget = pg.PlotWidget() self.plot_widget.getAxis('bottom').enableAutoSIPrefix(False) self.plot_widget.addLegend() self.curves = [] colors = self.golden_angle_color( - colormap='plasma', num=max(10, len(self.curves) + 1), format="HEX" + colormap='plasma', + num=2, + format="HEX", ) + self.color_impenetrable = self.impenetrable_color() - for idx, element in enumerate(self.chart_data): + self.data = { + 'assistant': [[0, 1000, 2000], [0, 20, 30]], + 'reality': [[0, 1000, 2000], [0, 18, 36]], + } + self.pipes = [] + self.walls = [] + + for idx, element in enumerate(self.data): self.curves.append( self.plot_widget.plot( [], @@ -430,10 +432,62 @@ class PlotWidget(QWidget): ) ) - self._layout.addWidget(self.plot_widget) + self.plot_group = Group( + 'Side View', + [ + self.plot_widget, + ] + ) - self.plot_widget.setLabel('left', 'Temperature [°C]') - self.plot_widget.setLabel('bottom', 'Time') + self.plot_widget.setLabel('left', 'Height [mm]') + self.plot_widget.setLabel('bottom', 'Distance [mm]') + self.plot_widget.setMouseEnabled(x=False, y=False) + self.plot_widget.setXRange(0, 25000, padding=0.1) + self.plot_widget.setYRange(-20, 120, padding=0.1) + self.plot_widget.setMenuEnabled(False) + self.plot_widget.hideButtons() + + self._layout.addWidget(self.plot_group) + self._layout.addStretch() + + self.plot_vacuum_pipes() + self.plot_walls() + self.update_curves() + + def plot_vacuum_pipes(self): + for i, _ in enumerate(bl.vacuum_pipes.center): + top = bl.vacuum_pipes.center[i] + bl.vacuum_pipes.diameter[i]/2 + bl.sourceHeight + bottom = bl.vacuum_pipes.center[i] - bl.vacuum_pipes.diameter[i]/2 + bl.sourceHeight + self.pipes.append(self.plot_widget.plot( + x=np.array([bl.vacuum_pipes.start[i], bl.vacuum_pipes.end[i]]), + y=np.array([top, top]), + pen=pg.mkPen(color=self.color_impenetrable, width=2), + )) + self.pipes.append(self.plot_widget.plot( + x=np.array([bl.vacuum_pipes.start[i], bl.vacuum_pipes.end[i]]), + y=np.array([bottom, bottom]), + pen=pg.mkPen(color=self.color_impenetrable, width=2), + )) + + def plot_walls(self): + for i, _ in enumerate(bl.walls.start): + rect = pg.QtWidgets.QGraphicsRectItem( # pylint: disable=E1101 + bl.walls.start[i], + bl.walls.height[i][0], + bl.walls.end[i] - bl.walls.start[i], + bl.walls.height[i][1] - bl.walls.height[i][0], + ) + rect.setBrush(pg.QtGui.QBrush(pg.QtGui.QColor(*self.color_impenetrable))) # pylint: disable=E1101 + rect.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) + self.plot_widget.addItem(rect) + + def impenetrable_color(self): + app = QApplication.instance() + theme = app.theme.theme # type: ignore + if theme == "light": + return (30, 30, 30) + else: + return (220, 220, 220) def golden_angle_color( self, @@ -470,10 +524,10 @@ class PlotWidget(QWidget): positions = min_pos + positions * (max_pos - min_pos) # Sample colors from the colormap at the calculated positions - colors = cmap.map(positions, mode="float") + colors = cmap.map(positions, mode="float") # type: ignore color_list = [] - for color in colors: + for color in colors: # type: ignore if format.upper() == "HEX": color_list.append(QColor.fromRgbF(*color).name()) elif format.upper() == "RGB": @@ -505,7 +559,7 @@ class PlotWidget(QWidget): if theme is None: app = QApplication.instance() if hasattr(app, "theme"): - theme = app.theme.theme + theme = app.theme.theme # type: ignore if theme == "light": min_pos = 0.0 @@ -516,17 +570,12 @@ class PlotWidget(QWidget): return min_pos, max_pos - def update_curves(self, timestamps: list[str], data: list[float]): - x = timestamps.copy() - y = data.copy() - min_len = min([min([len(i) for i in y]), len(x)]) - x_float = [t.timestamp() for t in x] - for idx, element in enumerate(y): - self.curves[idx].setData(x=np.array(x_float)[0:min_len], y=np.array(element)[0:min_len]) - -class TimeAxis(pg.AxisItem): - def tickStrings(self, values, scale, spacing): - return [datetime.fromtimestamp(value).strftime("%H:%M:%S") for value in values] + def update_curves(self): + for idx, element in enumerate(self.data): + self.curves[idx].setData( + x=np.array(self.data[element][0]), + y=np.array(self.data[element][1]), + ) # --------------------------------------------------------- Standalone run --- @@ -537,10 +586,10 @@ if __name__ == "__main__": from bec_widgets.utils.colors import apply_theme app = QApplication(sys.argv) - apply_theme("dark") + apply_theme("light") dispatcher = BECDispatcher(gui_id="digital_twin") win = DigitalTwin() - win.resize(1000, 800) + # win.resize(1000, 800) win.show() sys.exit(app.exec_()) \ No newline at end of file diff --git a/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py b/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py index 1ddd98b..cb5db48 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py @@ -289,3 +289,23 @@ smpl = sample( smpl2 = sample( name='EH-SMPL2', center=[0, 27500, sourceHeight],) + +# Vacuum pipes +# DN40CF ID = 35 mm oder 37 mm +# DN50CF ID = 47.5 mm +# DN63CF ID = 60.2 mm oder 66 mm +# DN100CF ID = 97.4 mm oder 104 mm +pipe = namedtuple('pipes', ['center', 'diameter', 'start', 'end']) +vacuum_pipes = pipe( + center= [27.5, (37.5+27.5)/2, 37.5, 62.5, 72.5], + diameter=[97.4, 97.4, 97.4, 97.4, 97.4], + start= [10952.88, 11750+250, mo2.center[1]+250, 14000, fm.center[1]], + end= [11750-250, mo2.center[1]-250, 14000, fm.center[1], ehWindow.center[1]], +) + +Walls = namedtuple('walls', ['start', 'end', 'height']) +walls = Walls( + start= [13999.30], + end= [13999+75.5+30], + height= [[-20, 25]], +) diff --git a/debye_bec/bec_widgets/widgets/qt_widgets.py b/debye_bec/bec_widgets/widgets/qt_widgets.py index 14d0fdd..2ccb69b 100644 --- a/debye_bec/bec_widgets/widgets/qt_widgets.py +++ b/debye_bec/bec_widgets/widgets/qt_widgets.py @@ -10,9 +10,9 @@ from qtpy.QtGui import QFont class Group(QGroupBox): def __init__(self, label, widgets): super().__init__(label) - self.layout = QVBoxLayout(self) + self.layout = QVBoxLayout(self) # type: ignore for widget in widgets: - self.layout.addWidget(widget) + self.layout.addWidget(widget) # type: ignore # class TextIndicator(QWidget): # def __init__(self, label, unit=None, highlight=False): @@ -94,8 +94,8 @@ class InputTextField(QWidget): def has_focus(self) -> bool: return self.val.hasFocus() - def value(self) -> float: - return self.val.val() + def text(self) -> str: + return self.val.text() def set_on_return(self, func): """Connect a function to the Enter/Return key press.""" From 576c59f5e5a21e1ad1966f30c1980432d4ca46e2 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 30 Apr 2026 15:45:28 +0200 Subject: [PATCH 32/54] Added angle signal for digital twin --- debye_bec/devices/mo1_bragg/mo1_bragg_devices.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py b/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py index ac5d451..f1cf471 100644 --- a/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py +++ b/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py @@ -244,6 +244,8 @@ class Mo1BraggPositioner(Device, PositionerBase): high_lim = Cpt(EpicsSignalRO, suffix="hi_lim_pos_energy_RBV", kind="config", auto_monitor=True) velocity = Cpt(EpicsSignalWithRBV, suffix="move_velocity", kind="config", auto_monitor=True) + angle = Cpt(EpicsSignalRO, suffix="feedback_pos_angle_RBV", kind="config", auto_monitor=True) + ########## Move Command PVs ########## move_abs = Cpt(EpicsSignal, suffix="move_abs", kind="config", put_complete=True) From 4ca59c57bef5d72cad8f2dfe37c3883ebd0b23b3 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 30 Apr 2026 15:46:05 +0200 Subject: [PATCH 33/54] wip: digital twin --- .../digital_twin/calculate_positions.py | 30 ++++---- .../digital_twin/calculate_sideview.py | 42 +++++++++++ .../widgets/digital_twin/digital_twin.py | 71 ++++++++++++++++--- 3 files changed, 118 insertions(+), 25 deletions(-) create mode 100644 debye_bec/bec_widgets/widgets/digital_twin/calculate_sideview.py diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py b/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py index 6edfd10..eda5698 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py @@ -54,10 +54,10 @@ def calc_positions(cfg): # Bragg Angle # TODO Should the bragg angle be corrected for the symmetric bragg case? # See raytracing script or here: bragg = np.asin(rm.ch / (2.*cfg['dSpacing']*cfg['energyCCM'])) - aCrystal.get_dtheta_symmetric_Bragg(cfg['energyCCM']) - if cfg['mo_mode'] == 'Monochromatic': + if cfg['mo1_mode'] == 'Monochromatic': # Add 2x CM pitch to the bragg angle - bragg = ((2 * cfg['cm_pitch']) + cfg['mo_bragg']) / np.pi * 180 - elif cfg['mo_mode'] == 'Pinkbeam': + bragg = ((2 * cfg['cm_pitch']) + cfg['mo1_bragg']) / np.pi * 180 + elif cfg['mo1_mode'] == 'Pinkbeam': # Align xtal surfaces parallel to beam bragg = (2 * cfg['cm_pitch']) / np.pi * 180 else: @@ -65,13 +65,13 @@ def calc_positions(cfg): pos['mo1_bragg_angle'] = {'value': bragg} # Bragg angle in deg # TRY, Height - l = bl.mo1.xtalGap[0]/np.sin(cfg['mo_bragg']) - yhor = l*np.cos(2.*(cfg['mo_bragg']+cfg['cm_pitch'])) + l = bl.mo1.xtalGap[0]/np.sin(cfg['mo1_bragg']) + yhor = l*np.cos(2.*(cfg['mo1_bragg']+cfg['cm_pitch'])) yver = yhor*np.tan(2.*cfg['cm_pitch']) - if cfg['mo_mode'] == 'Monochromatic': - beamOffsetCCM = l*np.sin(2.*(cfg['mo_bragg']+cfg['cm_pitch']))-yver # Resultat ist korrekt! - elif cfg['mo_mode'] == 'Pinkbeam': + if cfg['mo1_mode'] == 'Monochromatic': + beamOffsetCCM = l*np.sin(2.*(cfg['mo1_bragg']+cfg['cm_pitch']))-yver # Resultat ist korrekt! + elif cfg['mo1_mode'] == 'Pinkbeam': beamOffsetCCM = 0 else: raise Exception('Monochromator mode not supported') @@ -87,13 +87,13 @@ def calc_positions(cfg): # logger.info(f'f = {f}') d = bl.mo1.heightOffset # xtal height offset, mm # logger.info(f'd = {d}') - c = d*csc(cfg['mo_bragg'])-f*cot(cfg['mo_bragg']) + c = d*csc(cfg['mo1_bragg'])-f*cot(cfg['mo1_bragg']) # logger.info(f'c = {c}') # Calculate height of center of rotation - b = np.sqrt(d**2*csc(cfg['mo_bragg'])**2-2*d*f*cot(cfg['mo_bragg'])*csc(cfg['mo_bragg'])+f**2*cot(cfg['mo_bragg'])**2+f**2) + b = np.sqrt(d**2*csc(cfg['mo1_bragg'])**2-2*d*f*cot(cfg['mo1_bragg'])*csc(cfg['mo1_bragg'])+f**2*cot(cfg['mo1_bragg'])**2+f**2) # logger.info(f'b = {b}') - h = np.cos(np.pi/2-np.arctan(f/c)-cfg['mo_bragg']-2*cfg['cm_pitch'])*b + h = np.cos(np.pi/2-np.arctan(f/c)-cfg['mo1_bragg']-2*cfg['cm_pitch'])*b # logger.info(f'h = {h}') h2 = ((bl.mo1.center[1] - bl.cm.center[1])-np.sqrt(b**2-h**2))*np.tan(2*cfg['cm_pitch']) # logger.info(f'mo1 = {bl.mo1.center[1]}') @@ -103,18 +103,18 @@ def calc_positions(cfg): #TODO Mono height not exactly the same as in raytracing heightCCM1real = h + h2 # per design, the height should not change if the pitch of the CM is not changed! # heightCCM1real = heightCCM1real - 30 # Zero position of stage is at 1430 mm from ground. - if cfg['mo_mode'] == 'Monochromatic': + if cfg['mo1_mode'] == 'Monochromatic': pass - elif cfg['mo_mode'] == 'Pinkbeam': + elif cfg['mo1_mode'] == 'Pinkbeam': heightCCM1real = heightCCM1real - 13 # Move down to let beam pass between both crystal without touching copper cooler else: raise Exception('Monochromator mode not supported') pos['mo1_try'] = {'value': heightCCM1real} # TRX, Crystal selection - if cfg['mo_mode'] == 'Monochromatic': + if cfg['mo1_mode'] == 'Monochromatic': try: - xtal = cfg['mo_xtal'].translate(str.maketrans('', '', '()')) # Remove brackets from xtal name to conform with parameters + xtal = cfg['mo1_xtal'].translate(str.maketrans('', '', '()')) # Remove brackets from xtal name to conform with parameters index = bl.mo1.xtal.index(xtal) except: raise ValueError(f"Requested xtal {xtal} not found in parameters!") diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calculate_sideview.py b/debye_bec/bec_widgets/widgets/digital_twin/calculate_sideview.py new file mode 100644 index 0000000..6312a09 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/digital_twin/calculate_sideview.py @@ -0,0 +1,42 @@ +import numpy as np +import debye_bec.bec_widgets.widgets.digital_twin.x01da_parameters as bl + +def calc_sideview(cfg): + + # Calculate height of beam after CM + height = 2 * bl.cm.center[1] * np.tan(cfg['v_acc']) + + # beam height (Y=height, Z=along beam) + beam = {} + beam['Z'] = [] + beam['Y'] = [] + beam['Z'].append(0) # Source + beam['Y'].append(bl.sourceHeight) + beam['Z'].append(bl.cm.center[1]) # CM + beam['Y'].append(bl.sourceHeight) + if cfg['mo1_mode'] in 'Monochromatic': + diag = bl.mo1.xtalGap[0]/np.sin(cfg['mo1_bragg']) # Calculations for Mono + dy = diag*np.sin(2*(cfg['cm_pitch']+cfg['mo1_bragg'])) + dz = diag*np.cos(2*(cfg['cm_pitch']+cfg['mo1_bragg'])) + beam['Z'].append(bl.mo1.center[1]-dz/2) # Mono 1.1 + beam['Y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.mo1.center[1]-dz/2-bl.cm.center[1])) + beam['Z'].append(bl.mo1.center[1]+dz/2) # Mono 1.2 + beam['Y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.mo1.center[1]-dz/2-bl.cm.center[1])+dy) + beam['Z'].append(bl.fm.center[1]) # FM + beam['Y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1]-dz)+dy) + beam['Z'].append(cfg['smpl']) # Experiment + beam['Y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1]-dz)+dy+np.tan(2*(cfg['cm_pitch']-cfg['fm_pitch']))*(cfg['smpl']-bl.fm.center[1])) + elif cfg['mo1_mode'] == 'Pinkbeam': + beam['Z'].append(bl.fm.center[1]) # FM + beam['Y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1])) + beam['Z'].append(cfg['smpl']) # Experiment + beam['Y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1])+np.tan(2*(cfg['cm_pitch']-cfg['fm_pitch']))*(cfg['smpl']-bl.fm.center[1])) + + dy_fm_ex = beam['Y'][-1] - beam['Y'][-2] + dz_fm_ex = beam['Z'][-1] - beam['Z'][-2] + dz_fm_win = bl.ehWindow.center[1] - beam['Z'][-2] + h_at_win = beam['Y'][-2] + dy_fm_ex / dz_fm_ex * dz_fm_win + + beam['heightWindow'] = h_at_win + + return beam diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 608014b..97cf118 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -8,7 +8,7 @@ from qtpy.QtWidgets import ( QApplication, QLayout ) # pylint: disable=E0611 -from qtpy.QtCore import Qt +from qtpy.QtCore import Qt, QTimer from qtpy.QtGui import QColor, QFont import pyqtgraph as pg @@ -20,6 +20,7 @@ from bec_widgets.utils.error_popups import SafeSlot from debye_bec.bec_widgets.widgets.qt_widgets import InputNumberField, ComboBox, Group, NumberIndicator from debye_bec.bec_widgets.widgets.digital_twin.calculate_positions import calc_positions +from debye_bec.bec_widgets.widgets.digital_twin.calculate_sideview import calc_sideview import debye_bec.bec_widgets.widgets.digital_twin.x01da_parameters as bl @@ -79,29 +80,76 @@ class DigitalTwin(BECWidget, QWidget): self.calc_bragg_angle() self.calc_ideal_fm_pitch() self.calc_crit_angle() + self.calc_assistant_sideview() + self.calc_reality_sideview() - @SafeSlot() - def calc_positions(self, *args): - # logger.info(f'Got field {field} and number {qt_obj} and number {number} and args {args}') + # Timer: update plot every 1 second + self._timer = QTimer(self) + self._timer.setInterval(1000) + self._timer.timeout.connect(self.calc_reality_sideview) + # TODO: Check if I need to stop the timer if the widget is closed? + self._timer.start() + def get_assistant_config(self): config = { # Config in SI units! 'energy' : self.input.energy.value(), 'h_acc' : self.input.sldi_hacc.value() * 1e-3, 'v_acc' : self.input.sldi_vacc.value() * 1e-3, 'cm_pitch' : -self.input.cm_pitch.value() * 1e-3, 'cm_stripe' : self.input.cm_stripe.currentText(), - 'mo_mode' : self.input.mo1_mode.currentText(), - 'mo_xtal' : self.input.mo1_xtal.currentText(), - 'mo_bragg' : self.bragg_angle, + 'mo1_mode' : self.input.mo1_mode.currentText(), + 'mo1_xtal' : self.input.mo1_xtal.currentText(), + 'mo1_bragg' : self.bragg_angle, 'fm_pitch' : -self.input.fm_pitch.value() * 1e-3, 'fm_stripe' : self.input.fm_stripe.currentText(), 'fm_gain_height' : 1, 'smpl' : self.input.smpl.value(), } + # logger.info(f'Config created: {config}') + return config + + # TODO Needs to run in a loop in a separate thread due to the long time it takes to get the values from self.dev... + def get_reality_config(self): + if abs(self.dev.mo1_trx.position) > 5: + mo1_mode = 'Monochromatic' + else: + mo1_mode = 'Pinkbeam' + # TODO: stripe detection, mo1_bragg and acceptance + config = { # Config in SI units! + 'energy' : self.dev.mo1_bragg.position, + 'h_acc' : self.input.sldi_hacc.value() * 1e-3, + 'v_acc' : self.input.sldi_vacc.value() * 1e-3, + 'cm_pitch' : -self.dev.cm_rotx.position * 1e-3, + 'cm_stripe' : self.input.cm_stripe.currentText(), + 'mo1_mode' : mo1_mode, + 'mo1_xtal' : self.dev.mo1_bragg.crystal.current_xtal_string.get(), + 'mo1_bragg' : self.dev.mo1_bragg.angle.get(), + 'fm_pitch' : -self.dev.fm_rotx.position * 1e-3, + 'fm_stripe' : self.input.fm_stripe.currentText(), + 'fm_gain_height' : 1, + 'smpl' : self.dev.ot_es1_trz.position, + } + # logger.info(f'Config created: {config}') + return config - logger.info(f'Config created: {config}') - out = calc_positions(config) - logger.info(f'Got positions: {out}') + @SafeSlot() + def calc_assistant_sideview(self, *args): + beam = calc_sideview(self.get_assistant_config()) + self.plot_widget.data['assistant'][0] = beam['Z'] + self.plot_widget.data['assistant'][1] = beam['Y'] + self.plot_widget.update_curves() + + @SafeSlot() + def calc_reality_sideview(self): + logger.info('Update reality plot') + beam = calc_sideview(self.get_reality_config()) + self.plot_widget.data['reality'][0] = beam['Z'] + self.plot_widget.data['reality'][1] = beam['Y'] + self.plot_widget.update_curves() + + @SafeSlot() + def calc_positions(self, *args): + out = calc_positions(self.get_assistant_config()) self.positions.sldi_gapx.setValue(out['sldi_gapx']['value']) self.positions.sldi_gapy.setValue(out['sldi_gapy']['value']) @@ -124,6 +172,9 @@ class DigitalTwin(BECWidget, QWidget): self.positions.ot_rotx.setValue(out['ot_rotx']['value']) self.positions.ot_es1_trz.setValue(out['ot_es1_trz']['value']) + # TODO move to somewhere else! + self.calc_assistant_sideview() + @SafeSlot() def calc_bragg_angle(self, *args): """ From 3d756469e3360dfbce65563b9101e1d391c9a3ca Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 4 May 2026 06:52:01 +0200 Subject: [PATCH 34/54] Change of kind for angle signal --- debye_bec/devices/mo1_bragg/mo1_bragg_devices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py b/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py index f1cf471..bbe0c1b 100644 --- a/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py +++ b/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py @@ -244,7 +244,7 @@ class Mo1BraggPositioner(Device, PositionerBase): high_lim = Cpt(EpicsSignalRO, suffix="hi_lim_pos_energy_RBV", kind="config", auto_monitor=True) velocity = Cpt(EpicsSignalWithRBV, suffix="move_velocity", kind="config", auto_monitor=True) - angle = Cpt(EpicsSignalRO, suffix="feedback_pos_angle_RBV", kind="config", auto_monitor=True) + angle = Cpt(EpicsSignalRO, suffix="feedback_pos_angle_RBV", kind="normal", auto_monitor=True) ########## Move Command PVs ########## From 09799554ba098b86e9f389f9617a21d1ff0d207d Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 4 May 2026 06:52:30 +0200 Subject: [PATCH 35/54] signal name correction --- debye_bec/device_configs/x01da_optics.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debye_bec/device_configs/x01da_optics.yaml b/debye_bec/device_configs/x01da_optics.yaml index ce1a27e..d362794 100644 --- a/debye_bec/device_configs/x01da_optics.yaml +++ b/debye_bec/device_configs/x01da_optics.yaml @@ -259,7 +259,7 @@ fm_rotz: enabled: true softwareTrigger: false -fm_xctp: +fm_trx: readoutPriority: baseline description: Focusing Morror Center Point X deviceClass: ophyd.EpicsMotor @@ -269,7 +269,7 @@ fm_xctp: enabled: true softwareTrigger: false -fm_ytcp: +fm_try: readoutPriority: baseline description: Focusing Morror Center Point Y deviceClass: ophyd.EpicsMotor From b14f2c0fe3dec387b9d4b6918f350f697d0aa5c2 Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 4 May 2026 06:52:48 +0200 Subject: [PATCH 36/54] wip: digital twin --- .../widgets/digital_twin/calc_surfaces.py | 119 +++++ .../widgets/digital_twin/digital_twin.py | 451 ++++++++++++++++-- debye_bec/bec_widgets/widgets/qt_widgets.py | 3 + 3 files changed, 535 insertions(+), 38 deletions(-) create mode 100644 debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py b/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py new file mode 100644 index 0000000..25a129b --- /dev/null +++ b/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py @@ -0,0 +1,119 @@ +import os +import re +import numpy as np +from bec_lib import bec_logger + +os.environ["USE_XRT"] = "False" +import debye_bec.bec_widgets.widgets.digital_twin.x01da_parameters as bl + +def calc_surfaces(cfg): + + out = { + 'cm': {'x': [], 'y': []}, + 'mo1_1': {'x': [], 'y': []}, + 'mo1_2': {'x': [], 'y': []}, + 'fm': {'x': [], 'y': []}, + } + + # Collimating mirror + l = 2 * bl.cm.center[1] * np.tan(cfg['v_acc'])/np.sin(cfg['cm_pitch']) + + w1 = 2 * (bl.cm.center[1]-l/2) * np.tan(cfg['h_acc']) + w2 = 2 * (bl.cm.center[1]+l/2) * np.tan(cfg['h_acc']) + + index = bl.cm.surface.index(cfg['cm_stripe']) + cen = (bl.cm.limOptX[0][index] + bl.cm.limOptX[1][index]) / 2 + + out['cm']['x'] = [cen-w1/2, cen-w2/2, cen+w2/2, cen+w1/2] + out['cm']['y'] = [-l/2, l/2, l/2, -l/2] + + + # Monochromator + # calculate height of center of first crystal surface + c = bl.mo1.heightOffset*1/np.sin(cfg['mo1_bragg'])-bl.mo1.rotOffset*1/np.tan(cfg['mo1_bragg']) + e = bl.mo1.xtalGap[0]/np.tan(cfg['mo1_bragg'])-c + + xtal = cfg['mo1_xtal'].translate(str.maketrans('', '', '()')) # Remove brackets from xtal name to conform with parameters + index = bl.mo1.xtal.index(xtal) + + xtalPos = bl.mo1.xtalOffsetX[index] + xtalLength1 = bl.mo1.xtalLength1[index] + xtalLength2 = bl.mo1.xtalLength2[index] + + widthBeam = 2 * bl.mo1.center[1] * np.tan(cfg['h_acc']) + + heightBeam = 2 * bl.cm.center[1] * np.tan(cfg['v_acc']) + w = heightBeam / np.sin(cfg['mo1_bragg']) + + if cfg['mo1_mode'] in 'Monochromatic': + out['mo1_1']['x'] = [xtalPos-widthBeam/2, xtalPos+widthBeam/2, xtalPos+widthBeam/2, xtalPos-widthBeam/2] + out['mo1_1']['y'] = [xtalLength1/2-c-w/2, xtalLength1/2-c-w/2, xtalLength1/2-c+w/2, xtalLength1/2-c+w/2] + out['mo1_2']['x'] = [xtalPos-widthBeam/2, xtalPos+widthBeam/2, xtalPos+widthBeam/2, xtalPos-widthBeam/2] + out['mo1_2']['y'] = [-xtalLength2/2+e-w/2, -xtalLength2/2+e-w/2, -xtalLength2/2+e+w/2, -xtalLength2/2+e+w/2] + else: # Pinkbeam + out['mo1_1']['x'] = [] + out['mo1_1']['y'] = [] + out['mo1_2']['x'] = [] + out['mo1_2']['y'] = [] + + # Focusing mirror + + if cfg['fm_stripe'] in ('Rh (toroid)', 'Pt (toroid)'): + surface = bl.fm.surfaceToroid + stripe = re.sub(r'\s*\(.*?\)', '', cfg['fm_stripe']).strip() + index = surface.index(stripe) + off = (bl.fm.limOptXToroid[0][index] + bl.fm.limOptXToroid[1][index]) / 2 + r = bl.fm.r[index] + else: + surface = bl.fm.surfaceFlat + stripe = re.sub(r'\s*\(.*?\)', '', cfg['fm_stripe']).strip() + index = surface.index(stripe) + off = (bl.fm.limOptXFlat[0][index] + bl.fm.limOptXFlat[1][index]) / 2 + r = bl.fm.r[index] + + widthBeam = 2 * bl.fm.center[1] * np.tan(cfg['h_acc']) + + if cfg['fm_stripe'] in ('Rh (toroid)', 'Pt (toroid)'): + + l = heightBeam/np.sin(cfg['fm_pitch']) + alpha = np.arccos(1-widthBeam**2/(2*r**2)) + h = r-(r*np.cos(alpha/2)) + z = h/np.tan(cfg['fm_pitch']) + + x = [off-widthBeam/2, off-widthBeam/2] + y = [l/2-z/2, -l/2-z/2] + + res = 20 + xElipse = np.linspace(0, np.pi, res) + yElipse = np.linspace(0, np.pi, res) + xElipse = [-widthBeam/2*np.cos(i)+off for i in xElipse] + yElipse = [widthBeam*np.sin(i)*z/widthBeam-l/2-z/2 for i in yElipse] + + x.extend(xElipse) + y.extend(yElipse) + + x.extend([off+widthBeam/2, off+widthBeam/2]) + y.extend([-l/2-z/2, l/2-z/2]) + + res = 50 + xElipse = np.linspace(np.pi, 0, res) + yElipse = np.linspace(np.pi, 0, res) + xElipse = [-widthBeam/2*np.cos(i)+off for i in xElipse] + yElipse = [widthBeam*np.sin(i)*z/widthBeam+l/2-z/2 for i in yElipse] + + x.extend(xElipse) + y.extend(yElipse) + + out['fm']['x'] = x + out['fm']['y'] = y + + else: # flat surface, no toroid + l = heightBeam/np.sin(cfg['fm_pitch']) + + w1 = 2 * (bl.fm.center[1]-l/2) * np.tan(cfg['h_acc']) + w2 = 2 * (bl.fm.center[1]+l/2) * np.tan(cfg['h_acc']) + + out['fm']['x'] = [off-w1/2, off+w1/2, off+w2/2, off-w2/2] + out['fm']['y'] = [-l/2, -l/2, l/2, l/2] + + return out diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 97cf118..24258c6 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -1,11 +1,13 @@ import sys +import re import datetime import numpy as np +from scipy.interpolate import UnivariateSpline from bec_lib import bec_logger # pylint: disable=E0611 from qtpy.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QApplication, QLayout + QApplication, QLayout, QGroupBox ) # pylint: disable=E0611 from qtpy.QtCore import Qt, QTimer @@ -21,6 +23,7 @@ from debye_bec.bec_widgets.widgets.qt_widgets import InputNumberField, ComboBox, from debye_bec.bec_widgets.widgets.digital_twin.calculate_positions import calc_positions from debye_bec.bec_widgets.widgets.digital_twin.calculate_sideview import calc_sideview +from debye_bec.bec_widgets.widgets.digital_twin.calc_surfaces import calc_surfaces import debye_bec.bec_widgets.widgets.digital_twin.x01da_parameters as bl @@ -43,18 +46,23 @@ class DigitalTwin(BECWidget, QWidget): central = QWidget() self.root_layout = QHBoxLayout(central) + self.plot_widget = QWidget() + self.plot_layout = QVBoxLayout(self.plot_widget) self.input = InputPanel() - self.plot_widget = PlotWidget() + self.sideview_plot = SideviewPlot() + self.surface_plots = SurfacePlots() self.positions = PositionsPanel() self.root_layout.addWidget(self.input, stretch=1, alignment=Qt.AlignTop) # type: ignore + self.plot_layout.addWidget(self.sideview_plot) # type: ignore + self.plot_layout.addWidget(self.surface_plots) # type: ignore self.root_layout.addWidget(self.plot_widget, stretch=1, alignment=Qt.AlignTop) # type: ignore self.root_layout.addWidget(self.positions, stretch=1, alignment=Qt.AlignTop) # type: ignore self.setLayout(self.root_layout) self.setWindowTitle("Digital Twin") - # self.resize(1500, 800) + self.resize(1800, 800) self.input.energy.value_changed_connect(self.calc_bragg_angle) self.input.sldi_hacc.value_changed_connect(self.calc_positions) @@ -76,12 +84,25 @@ class DigitalTwin(BECWidget, QWidget): self.input.fm_stripe.activated_connect(self.calc_ideal_fm_pitch) self.input.smpl.value_changed_connect(self.calc_ideal_fm_pitch) + self.input.energy.value_changed_connect(self.calc_cm_reflectivity) + self.input.cm_pitch.value_changed_connect(self.calc_cm_reflectivity) + self.input.cm_stripe.activated_connect(self.calc_cm_reflectivity) + self.input.fm_pitch.value_changed_connect(self.calc_fm_reflectivity) + self.input.fm_stripe.activated_connect(self.calc_fm_reflectivity) + self.input.energy.value_changed_connect(self.calc_cm_reflectivity) + + self.input.energy.value_changed_connect(self.calc_energy_resolution) + self.input.mo1_xtal.activated_connect(self.calc_energy_resolution) + self.bragg_angle = 0 self.calc_bragg_angle() self.calc_ideal_fm_pitch() self.calc_crit_angle() self.calc_assistant_sideview() self.calc_reality_sideview() + self.calc_cm_reflectivity() + self.calc_fm_reflectivity() + self.calc_energy_resolution() # Timer: update plot every 1 second self._timer = QTimer(self) @@ -90,6 +111,67 @@ class DigitalTwin(BECWidget, QWidget): # TODO: Check if I need to stop the timer if the widget is closed? self._timer.start() + def calc_energy_resolution(self, *args): + xtal = self.input.mo1_xtal.currentText().translate(str.maketrans('', '', '()')) # Remove brackets from xtal name to conform with parameters + index = bl.mo1.xtal.index(xtal) + crystal = bl.mo1.material1[index] + E = self.input.energy.value() + + dtheta = np.linspace(-30, 90, 601) + theta = crystal.get_Bragg_angle(E) + dtheta * 1e-6 + refl = np.abs(crystal.get_amplitude(E, np.sin(theta))[0])**2 # single crystal + + refl2 = refl**2 # DCM with parallel crystals + + # FWHM of the DCM curve + spline = UnivariateSpline(dtheta, refl2 - refl2.max()/2, s=0) + r1, r2 = spline.roots() + fwhm_rad = (r2 - r1) * 1e-6 # µrad → rad + + # Energy resolution + theta_B = crystal.get_Bragg_angle(E) + dE_over_E = fwhm_rad / np.tan(theta_B) + dE = dE_over_E * E + + logger.info(f"DCM FWHM : {r2-r1:.2f} µrad") + logger.info(f"ΔE/E : {dE_over_E:.2e}") + logger.info(f"ΔE : {dE:.3f} eV at {E} eV") + + self.input.mo1_eres.setValue(dE) + + def calc_cm_reflectivity(self, *args): + index = bl.cm.surface.index(self.input.cm_stripe.currentText()) + rs, rp = bl.cm.material[index].get_amplitude( + self.input.energy.value(), + np.sin(-self.input.cm_pitch.value() * 1e-3) + )[0:2] + self.input.cm_refl.setValue(100 * abs(rs)**2) + self.input.cm_refl.setLabel(f"Refl. at {self.input.energy.value():.0f} eV") + rs, rp = bl.cm.material[index].get_amplitude( + 2 * self.input.energy.value(), + np.sin(-self.input.cm_pitch.value() * 1e-3) + )[0:2] + self.input.cm_refl_harm.setValue(100 * abs(rs)**2) + self.input.cm_refl_harm.setLabel(f"Refl. at {2 * self.input.energy.value():.0f} eV") + + def calc_fm_reflectivity(self, *args): + if self.input.fm_stripe.currentText() in ('Rh (toroid)', 'Pt (toroid)'): + surface = bl.fm.surfaceToroid + material = bl.fm.materialToroid + stripe = re.sub(r'\s*\(.*?\)', '', self.input.fm_stripe.currentText()).strip() + index = surface.index(stripe) + else: + surface = bl.fm.surfaceFlat + material = bl.fm.materialFlat + stripe = re.sub(r'\s*\(.*?\)', '', self.input.fm_stripe.currentText()).strip() + index = surface.index(stripe) + rs, rp = material[index].get_amplitude( + self.input.energy.value(), + np.sin(-self.input.fm_pitch.value() * 1e-3) + )[0:2] + self.input.fm_refl.setValue(100 * abs(rs)**2) + self.input.fm_refl.setLabel(f"Refl. at {self.input.energy.value():.0f} eV") + def get_assistant_config(self): config = { # Config in SI units! 'energy' : self.input.energy.value(), @@ -110,24 +192,42 @@ class DigitalTwin(BECWidget, QWidget): # TODO Needs to run in a loop in a separate thread due to the long time it takes to get the values from self.dev... def get_reality_config(self): - if abs(self.dev.mo1_trx.position) > 5: + if abs(self.dev.mo1_trx.read()['mo1_trx']['value']) > 5: mo1_mode = 'Monochromatic' else: mo1_mode = 'Pinkbeam' - # TODO: stripe detection, mo1_bragg and acceptance + mo1_bragg = self.dev.mo1_bragg.read() + sldi_gapx = self.dev.sldi_gapx.read()['sldi_gapx']['value'] + sldi_gapy = self.dev.sldi_gapy.read()['sldi_gapy']['value'] + d1 = bl.feSlits.center1[1] + h_acc = np.tan(sldi_gapx / (2 * d1)) + v_acc = np.tan(sldi_gapy / (2 * d1)) + cm_trx = -self.dev.cm_trx.read()['cm_trx']['value'] + cm_stripe = None + for name, low, high in zip(bl.cm.surface, bl.cm.limOptX[0], bl.cm.limOptX[1]): + if low <= cm_trx <= high: + cm_stripe = name + fm_trx = -self.dev.fm_trx.read()['fm_trx']['value'] + fm_stripe = None + for name, low, high in zip(bl.fm.surfaceFlat, bl.fm.limOptXFlat[1], bl.fm.limOptXFlat[0]): + if low <= fm_trx <= high: + fm_stripe = name + ' (flat)' + for name, low, high in zip(bl.fm.surfaceToroid, bl.fm.limOptXToroid[1], bl.fm.limOptXToroid[0]): + if low <= fm_trx <= high: + fm_stripe = name + ' (toroid)' config = { # Config in SI units! - 'energy' : self.dev.mo1_bragg.position, - 'h_acc' : self.input.sldi_hacc.value() * 1e-3, - 'v_acc' : self.input.sldi_vacc.value() * 1e-3, - 'cm_pitch' : -self.dev.cm_rotx.position * 1e-3, - 'cm_stripe' : self.input.cm_stripe.currentText(), + 'energy' : mo1_bragg['mo1_bragg']['value'], + 'h_acc' : h_acc, + 'v_acc' : v_acc, + 'cm_pitch' : -self.dev.cm_rotx.read()['cm_rotx']['value'] * 1e-3, + 'cm_stripe' : cm_stripe, 'mo1_mode' : mo1_mode, - 'mo1_xtal' : self.dev.mo1_bragg.crystal.current_xtal_string.get(), - 'mo1_bragg' : self.dev.mo1_bragg.angle.get(), - 'fm_pitch' : -self.dev.fm_rotx.position * 1e-3, - 'fm_stripe' : self.input.fm_stripe.currentText(), + 'mo1_xtal' : mo1_bragg['mo1_bragg_crystal_current_xtal_string']['value'], + 'mo1_bragg' : mo1_bragg['mo1_bragg_angle']['value']/180*np.pi, + 'fm_pitch' : -self.dev.fm_rotx.read()['fm_rotx']['value'] * 1e-3, + 'fm_stripe' : fm_stripe, 'fm_gain_height' : 1, - 'smpl' : self.dev.ot_es1_trz.position, + 'smpl' : self.dev.ot_es1_trz.read()['ot_es1_trz']['value'], } # logger.info(f'Config created: {config}') return config @@ -135,17 +235,30 @@ class DigitalTwin(BECWidget, QWidget): @SafeSlot() def calc_assistant_sideview(self, *args): beam = calc_sideview(self.get_assistant_config()) - self.plot_widget.data['assistant'][0] = beam['Z'] - self.plot_widget.data['assistant'][1] = beam['Y'] - self.plot_widget.update_curves() + self.sideview_plot.data['assistant']['x'] = beam['Z'] + self.sideview_plot.data['assistant']['y'] = beam['Y'] + self.sideview_plot.update_curves() @SafeSlot() def calc_reality_sideview(self): - logger.info('Update reality plot') + # logger.info('Update reality plot') beam = calc_sideview(self.get_reality_config()) - self.plot_widget.data['reality'][0] = beam['Z'] - self.plot_widget.data['reality'][1] = beam['Y'] - self.plot_widget.update_curves() + self.sideview_plot.data['reality']['x'] = beam['Z'] + self.sideview_plot.data['reality']['y'] = beam['Y'] + self.sideview_plot.update_curves() + + # TODO Move to different place + self.calc_reality_surfaces() + + @SafeSlot() + def calc_assistant_surfaces(self, *args): + surfaces = calc_surfaces(self.get_assistant_config()) + self.surface_plots.update_surfaces(scene='assistant', data=surfaces) + + @SafeSlot() + def calc_reality_surfaces(self, *args): + surfaces = calc_surfaces(self.get_reality_config()) + self.surface_plots.update_surfaces(scene='reality', data=surfaces) @SafeSlot() def calc_positions(self, *args): @@ -174,6 +287,7 @@ class DigitalTwin(BECWidget, QWidget): # TODO move to somewhere else! self.calc_assistant_sideview() + self.calc_assistant_surfaces() @SafeSlot() def calc_bragg_angle(self, *args): @@ -197,11 +311,20 @@ class DigitalTwin(BECWidget, QWidget): self.bragg_angle = 0 if val > -1 and val < 1: self.bragg_angle = np.asin(val) + + cm_pitch = -self.dev.cm_rotx.read()['cm_rotx']['value'] * 1e-3 + if self.input.mo1_mode.currentText() in 'Monochromatic': + # Add 2x CM pitch to the bragg angle + bragg_angle_cor = ((2 * cm_pitch) + self.bragg_angle) / np.pi * 180 + elif self.input.mo1_mode.currentText() in 'Pinkbeam': + # Align xtal surfaces parallel to beam + bragg_angle_cor = (2 * cm_pitch) / np.pi * 180 + + self.input.mo1_bragg_angle.setValue(bragg_angle_cor) self.calc_positions() @SafeSlot() def update_mono_mode(self, *args): - logger.info(f'Got args {args}') if self.input.mo1_mode.currentText() in 'Monochromatic': self.input.mo1_xtal.setDisabled(False) else: @@ -247,7 +370,7 @@ class InputPanel(QWidget): self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore # Energy - self.energy = InputNumberField('Energy [keV]', init=8979, decimals=0, single_step=100, ll=4000, hl=65000) + self.energy = InputNumberField('Energy [eV]', init=8979, decimals=0, single_step=100, ll=4000, hl=65000) # FE Slits Acceptance self.sldi_hacc = InputNumberField('Horizontal [± mrad]', init=0.25, decimals=2, single_step=0.01, ll=-0.1, hl=0.9) @@ -264,23 +387,31 @@ class InputPanel(QWidget): self.cm_stripe = ComboBox('Stripe', ['Si', 'Rh', 'Pt']) self.cm_pitch_critical = NumberIndicator('Critical Pitch', 'mrad', decimals=3) self.cm_pitch = InputNumberField('Pitch [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-4.6, hl=-1.2) + self.cm_refl = NumberIndicator('Refl. at x eV', '%', decimals=0) + self.cm_refl_harm = NumberIndicator('Refl. at x eV', '%', decimals=0) self.cm_ass_group = Group( 'Collimating Mirror', [ self.cm_stripe, self.cm_pitch_critical, self.cm_pitch, + self.cm_refl, + self.cm_refl_harm, ] ) # Monochromator self.mo1_mode = ComboBox('Mode', ['Monochromatic', 'Pinkbeam']) self.mo1_xtal = ComboBox('Crystal', ['Si(111)', 'Si(311)']) + self.mo1_bragg_angle = NumberIndicator('Bragg Angle', 'deg', decimals=1) + self.mo1_eres = NumberIndicator('Energy Resolution', 'eV', decimals=2) self.mo1_ass_group = Group( 'Monochromator', [ self.mo1_mode, self.mo1_xtal, + self.mo1_bragg_angle, + self.mo1_eres, ] ) @@ -288,12 +419,14 @@ class InputPanel(QWidget): self.fm_stripe = ComboBox('Stripe', ['Rh (toroid)', 'Rh (flat)', 'Pt (toroid)', 'Pt (flat)']) self.fm_pitch_ideal = NumberIndicator('Ideal Pitch', 'mrad', decimals=3) self.fm_pitch = InputNumberField('Pitch [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-10, hl=2) + self.fm_refl = NumberIndicator('Refl. at x eV', '%', decimals=0) self.fm_ass_group = Group( 'Focusing Mirror', [ self.fm_stripe, self.fm_pitch_ideal, self.fm_pitch, + self.fm_refl, ] ) @@ -446,7 +579,242 @@ class PositionsPanel(QWidget): self._layout .addWidget(self.position_group) self._layout .addStretch() -class PlotWidget(QWidget): +class SurfacePlots(QWidget): + """Plot widget with two curves and legend.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._layout = QHBoxLayout(self) + # self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore + + self.surfaces = { + 'assistant': { + 'cm': {'x': [], 'y': []}, + 'mo1_1': {'x': [], 'y': []}, + 'mo1_2': {'x': [], 'y': []}, + 'fm': {'x': [], 'y': []}, + }, + 'reality': { + 'cm': {'x': [], 'y': []}, + 'mo1_1': {'x': [], 'y': []}, + 'mo1_2': {'x': [], 'y': []}, + 'fm': {'x': [], 'y': []}, + }, + } + + self.plots = { + 'cm': {}, + 'mo1_1': {}, + 'mo1_2': {}, + 'fm': {}, + } + + app = QApplication.instance() + theme = app.theme.theme # type: ignore + if theme == "light": + self.color_impenetrable = (30, 30, 30) + self.colors = [(79, 163, 224), (240, 128, 60)] + else: # dark theme + self.color_impenetrable = (220, 220, 220) + self.colors = [(26, 111, 173), (212, 83, 10)] + + + # Create plot widgets + for name, widget in self.plots.items(): + plot_widget = pg.PlotWidget() + plot_widget.getAxis('bottom').enableAutoSIPrefix(False) + + plot_group = Group( + 'Surface ' + name, + [ + plot_widget, + ] + ) + + plot_widget.setLabel('left', 'Z [mm]') + plot_widget.setLabel('bottom', 'X [mm]') + plot_widget.setMouseEnabled(x=False, y=False) + # plot_widget.setXRange(0, 25000, padding=0.1) + # plot_widget.setYRange(-20, 120, padding=0.1) + plot_widget.setMenuEnabled(False) + plot_widget.hideButtons() + + widget['widget'] = plot_widget + self._layout.addWidget(plot_group) + + # Create surfaces + for idx, scene in enumerate(self.surfaces): + for name, device in self.surfaces[scene].items(): + brush = pg.mkBrush(color=self.colors[idx] + (150,)) + widget = self.plots[name] + self.plots[name][scene] = widget['widget'].plot( + [], + [], + pen=None, + name=scene, + brush=brush, + fillLevel=0, + ) + self.plots[name][scene].setZValue(1) + + # self._layout.addStretch() + + logger.info(f'Created surfaces: {self.surfaces}') + logger.info(f'Created plots: {self.plots}') + + self.plot_walls() + # self.update_curves() + + def plot_walls(self): + + def plot_mirror_stripe(widget, surface, limOptX, limOptY): + for sf, lx, hx, ly, hy in zip(surface, limOptX[0], limOptX[1], limOptY[0], limOptY[1]): + rect = pg.QtWidgets.QGraphicsRectItem( # pylint: disable=E1101 + lx, + ly, + hx - lx, + hy - ly, + ) + rect.setBrush(pg.QtGui.QBrush(pg.QtGui.QColor(*self.color_impenetrable))) # pylint: disable=E1101 + rect.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) + widget.addItem(rect) + text = pg.TextItem(sf, color='w', anchor=(0.5, 0.5)) # TODO: CHange color according to theme + widget.addItem(text) + text.setPos((hx+lx)/2, (hy+ly)/2) + text.setZValue(2) + + def plot_mono_surface(widget, xtal, xtalWidth, xtalOffsetX, xtalLength): + for sf, w, offx, len in zip(xtal, xtalWidth, xtalOffsetX, xtalLength): + rect = pg.QtWidgets.QGraphicsRectItem( # pylint: disable=E1101 + offx - w/2, + -len/2, + w, + len, + ) + rect.setBrush(pg.QtGui.QBrush(pg.QtGui.QColor(*self.color_impenetrable))) # pylint: disable=E1101 + rect.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) + widget.addItem(rect) + text = pg.TextItem(sf, color='w', anchor=(0.5, 0.5)) # TODO: CHange color according to theme + widget.addItem(text) + text.setPos(offx, 0) + text.setZValue(2) + + for name, plot in self.plots.items(): + if name in 'cm': + plot_mirror_stripe(plot['widget'], bl.cm.surface, bl.cm.limOptX, bl.cm.limOptY) + elif name in 'mo1_1': + plot_mono_surface(plot['widget'], bl.mo1.xtal, bl.mo1.xtalWidth, bl.mo1.xtalOffsetX, bl.mo1.xtalLength1) + elif name in 'mo1_2': + plot_mono_surface(plot['widget'], bl.mo1.xtal, bl.mo1.xtalWidth, bl.mo1.xtalOffsetX, bl.mo1.xtalLength2) + elif name in 'fm': + plot_mirror_stripe(plot['widget'], bl.fm.surfaceFlat, bl.fm.limOptXFlat, bl.fm.limOptYFlat) + plot_mirror_stripe(plot['widget'], bl.fm.surfaceToroid, bl.fm.limOptXToroid, bl.fm.limOptYToroid) + else: + raise Exception(f'Plot {name} not found!') + for name, plot in self.plots.items(): + plot['widget'].disableAutoRange() + + def impenetrable_color(self): + app = QApplication.instance() + theme = app.theme.theme # type: ignore + if theme == "light": + return (30, 30, 30) + else: + return (220, 220, 220) + + def golden_angle_color( + self, + colormap: str, + num: int, + format="QColor", + theme_offset=0.2, + theme=None, + ) -> list: + """ + Extract num colors from the specified colormap following golden angle distribution and return them in the specified format. + + Args: + colormap (str): Name of the colormap. + num (int): Number of requested colors. + format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor'). + theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background. + + Returns: + list: List of colors in the specified format. + + Raises: + ValueError: If theme_offset is not between 0 and 1. + """ + + cmap = pg.colormap.get(colormap) + phi = (1 + np.sqrt(5)) / 2 # Golden ratio + golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125 + + min_pos, max_pos = self.set_theme_offset(theme, theme_offset) + + # Generate positions within the acceptable range + positions = np.mod(np.arange(num) * golden_angle_conjugate, 1) + positions = min_pos + positions * (max_pos - min_pos) + + # Sample colors from the colormap at the calculated positions + colors = cmap.map(positions, mode="float") # type: ignore + color_list = [] + + for color in colors: # type: ignore + if format.upper() == "HEX": + color_list.append(QColor.fromRgbF(*color).name()) + elif format.upper() == "RGB": + color_list.append(tuple((np.array(color) * 255).astype(int))) + elif format.upper() == "QCOLOR": + color_list.append(QColor.fromRgbF(*color)) + else: + raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.") + return color_list + + def set_theme_offset(self, theme = None, offset=0.2) -> tuple: + """ + Set the theme offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background. + + Args: + theme(str): The theme to be applied. + offset(float): Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background. + + Returns: + tuple: Tuple of min_pos and max_pos. + + Raises: + ValueError: If theme_offset is not between 0 and 1. + """ + + if offset < 0 or offset > 1: + raise ValueError("theme_offset must be between 0 and 1") + + if theme is None: + app = QApplication.instance() + if hasattr(app, "theme"): + theme = app.theme.theme # type: ignore + + if theme == "light": + min_pos = 0.0 + max_pos = 1 - offset + else: + min_pos = 0.0 + offset + max_pos = 1.0 + + return min_pos, max_pos + + def update_surfaces(self, scene, data): + self.surfaces[scene] = data + for name, device in self.surfaces[scene].items(): + plot = self.plots[name][scene] + x = np.array(device['x'] + [device['x'][0]]) if len(device['x']) != 0 else np.array([]) + y = np.array(device['y'] + [device['y'][0]]) if len(device['y']) != 0 else np.array([]) + plot.setData(x=x, y=y) + # fill = pg.FillBetweenItem(curve, widget.plot(device['x'], np.zeros(len(device['x'])), pen=None), brush=pg.mkBrush('b')) + # widget.addItem(fill) + logger.info(self.surfaces) + +class SideviewPlot(QWidget): """Plot widget with two curves and legend.""" def __init__(self, parent=None): @@ -458,28 +826,35 @@ class PlotWidget(QWidget): self.plot_widget.getAxis('bottom').enableAutoSIPrefix(False) self.plot_widget.addLegend() + app = QApplication.instance() + theme = app.theme.theme # type: ignore + if theme == "light": + self.color_impenetrable = (30, 30, 30) + self.colors = [(26, 111, 173), (212, 83, 10)] + else: # dark theme + self.color_impenetrable = (220, 220, 220) + self.colors = [(79, 163, 224), (240, 128, 60)] + self.curves = [] - colors = self.golden_angle_color( - colormap='plasma', - num=2, - format="HEX", - ) - self.color_impenetrable = self.impenetrable_color() self.data = { - 'assistant': [[0, 1000, 2000], [0, 20, 30]], - 'reality': [[0, 1000, 2000], [0, 18, 36]], + 'assistant': {'x': [0, 1000, 2000], 'y': [0, 20, 30]}, + 'reality': {'x': [0, 1000, 2000], 'y': [0, 15, 50]}, } self.pipes = [] self.walls = [] - for idx, element in enumerate(self.data): + for idx, name in enumerate(self.data.keys()): + if name in "assistant": + pen = pg.mkPen(color=self.colors[idx], width=2, style=Qt.DashLine) + else: + pen = pg.mkPen(color=self.colors[idx], width=2) self.curves.append( self.plot_widget.plot( [], [], - pen=pg.mkPen(color=colors[idx], width=2), - name=element, + pen=pen, + name=name, ) ) @@ -624,8 +999,8 @@ class PlotWidget(QWidget): def update_curves(self): for idx, element in enumerate(self.data): self.curves[idx].setData( - x=np.array(self.data[element][0]), - y=np.array(self.data[element][1]), + x=np.array(self.data[element]['x']), + y=np.array(self.data[element]['y']), ) diff --git a/debye_bec/bec_widgets/widgets/qt_widgets.py b/debye_bec/bec_widgets/widgets/qt_widgets.py index 2ccb69b..8a9d1df 100644 --- a/debye_bec/bec_widgets/widgets/qt_widgets.py +++ b/debye_bec/bec_widgets/widgets/qt_widgets.py @@ -66,6 +66,9 @@ class NumberIndicator(QWidget): def value(self) -> float: return self.number + def setLabel(self, label) -> None: + self.label.setText(label) + def setValue(self, number): self.number = number text = f'{number:.{int(self.decimals)}f}' From 16bd819a9fcbc2e10b5332c5c0e7ce55bbf12eb0 Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 4 May 2026 12:46:50 +0200 Subject: [PATCH 37/54] wip: digital twin --- .../widgets/digital_twin/digital_twin.py | 280 ++++-------------- debye_bec/bec_widgets/widgets/qt_widgets.py | 19 +- 2 files changed, 71 insertions(+), 228 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 24258c6..1ae45a1 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -11,7 +11,7 @@ from qtpy.QtWidgets import ( ) # pylint: disable=E0611 from qtpy.QtCore import Qt, QTimer -from qtpy.QtGui import QColor, QFont +from qtpy.QtGui import QColor, QFont, QBrush import pyqtgraph as pg from xrt.backends.raycing.physconsts import CHeVcm, AVOGADRO @@ -133,9 +133,9 @@ class DigitalTwin(BECWidget, QWidget): dE_over_E = fwhm_rad / np.tan(theta_B) dE = dE_over_E * E - logger.info(f"DCM FWHM : {r2-r1:.2f} µrad") - logger.info(f"ΔE/E : {dE_over_E:.2e}") - logger.info(f"ΔE : {dE:.3f} eV at {E} eV") + # logger.info(f"DCM FWHM : {r2-r1:.2f} µrad") + # logger.info(f"ΔE/E : {dE_over_E:.2e}") + # logger.info(f"ΔE : {dE:.3f} eV at {E} eV") self.input.mo1_eres.setValue(dE) @@ -146,13 +146,17 @@ class DigitalTwin(BECWidget, QWidget): np.sin(-self.input.cm_pitch.value() * 1e-3) )[0:2] self.input.cm_refl.setValue(100 * abs(rs)**2) - self.input.cm_refl.setLabel(f"Refl. at {self.input.energy.value():.0f} eV") + self.input.cm_refl.setLabel(f"Reflectivity at \n{self.input.energy.value():.0f} eV") rs, rp = bl.cm.material[index].get_amplitude( 2 * self.input.energy.value(), np.sin(-self.input.cm_pitch.value() * 1e-3) )[0:2] self.input.cm_refl_harm.setValue(100 * abs(rs)**2) - self.input.cm_refl_harm.setLabel(f"Refl. at {2 * self.input.energy.value():.0f} eV") + self.input.cm_refl_harm.setLabel(f"Reflectivity at \n{3 * self.input.energy.value():.0f} eV") + + harm_suppr = (self.input.cm_refl.value() * self.input.fm_refl.value()) / (self.input.cm_refl_harm.value() * self.input.fm_refl_harm.value()) + self.input.cm_fm_harm_suppr.setValue(harm_suppr) + self.input.cm_fm_harm_suppr.setLabel(f"Total Suppression Factor at {3 * self.input.energy.value():.0f} eV") def calc_fm_reflectivity(self, *args): if self.input.fm_stripe.currentText() in ('Rh (toroid)', 'Pt (toroid)'): @@ -170,7 +174,17 @@ class DigitalTwin(BECWidget, QWidget): np.sin(-self.input.fm_pitch.value() * 1e-3) )[0:2] self.input.fm_refl.setValue(100 * abs(rs)**2) - self.input.fm_refl.setLabel(f"Refl. at {self.input.energy.value():.0f} eV") + self.input.fm_refl.setLabel(f"Reflectivity at \n{self.input.energy.value():.0f} eV") + rs, rp = material[index].get_amplitude( + 2 * self.input.energy.value(), + np.sin(-self.input.fm_pitch.value() * 1e-3) + )[0:2] + self.input.fm_refl_harm.setValue(100 * abs(rs)**2) + self.input.fm_refl_harm.setLabel(f"Reflectivity at \n{3 * self.input.energy.value():.0f} eV") + + harm_suppr = (self.input.cm_refl.value() * self.input.fm_refl.value()) / (self.input.cm_refl_harm.value() * self.input.fm_refl_harm.value()) + self.input.cm_fm_harm_suppr.setValue(harm_suppr) + self.input.cm_fm_harm_suppr.setLabel(f"Total Suppression Factor at {3 * self.input.energy.value():.0f} eV") def get_assistant_config(self): config = { # Config in SI units! @@ -387,8 +401,8 @@ class InputPanel(QWidget): self.cm_stripe = ComboBox('Stripe', ['Si', 'Rh', 'Pt']) self.cm_pitch_critical = NumberIndicator('Critical Pitch', 'mrad', decimals=3) self.cm_pitch = InputNumberField('Pitch [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-4.6, hl=-1.2) - self.cm_refl = NumberIndicator('Refl. at x eV', '%', decimals=0) - self.cm_refl_harm = NumberIndicator('Refl. at x eV', '%', decimals=0) + self.cm_refl = NumberIndicator('Reflectivity at x eV', '%', decimals=0) + self.cm_refl_harm = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.cm_ass_group = Group( 'Collimating Mirror', [ @@ -419,7 +433,8 @@ class InputPanel(QWidget): self.fm_stripe = ComboBox('Stripe', ['Rh (toroid)', 'Rh (flat)', 'Pt (toroid)', 'Pt (flat)']) self.fm_pitch_ideal = NumberIndicator('Ideal Pitch', 'mrad', decimals=3) self.fm_pitch = InputNumberField('Pitch [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-10, hl=2) - self.fm_refl = NumberIndicator('Refl. at x eV', '%', decimals=0) + self.fm_refl = NumberIndicator('Reflectivity at x eV', '%', decimals=0) + self.fm_refl_harm = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.fm_ass_group = Group( 'Focusing Mirror', [ @@ -427,10 +442,12 @@ class InputPanel(QWidget): self.fm_pitch_ideal, self.fm_pitch, self.fm_refl, + self.fm_refl_harm, ] ) # Sample + self.cm_fm_harm_suppr = NumberIndicator('Total Suppression Factor at x eV', '', decimals=0) self.smpl = InputNumberField('Sample Position [mm]', init=23511, decimals=0, single_step=100, ll=23000, hl=30000) # Assemble complete assitant group @@ -442,6 +459,7 @@ class InputPanel(QWidget): self.cm_ass_group, self.mo1_ass_group, self.fm_ass_group, + self.cm_fm_harm_suppr, self.smpl, ] ) @@ -458,8 +476,8 @@ class PositionsPanel(QWidget): self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore # FE Slits - self.sldi_gapx = NumberIndicator('GAPX', 'mm', decimals=3) - self.sldi_gapy = NumberIndicator('GAPY', 'mm', decimals=3) + self.sldi_gapx = NumberIndicator('GAPX', 'mm', decimals=2) + self.sldi_gapy = NumberIndicator('GAPY', 'mm', decimals=2) self.sldi_pos_group = Group( 'FE Slits', [ @@ -469,8 +487,8 @@ class PositionsPanel(QWidget): ) # Collimating mirror - self.cm_trx = NumberIndicator('TRX', 'mm', decimals=1) - self.cm_try = NumberIndicator('TRY', 'mm', decimals=3) + self.cm_trx = NumberIndicator('TRX', 'mm', decimals=2) + self.cm_try = NumberIndicator('TRY', 'mm', decimals=2) self.cm_bnd = NumberIndicator('BENDER', 'km', decimals=2) self.cm_rotx = NumberIndicator('PITCH', 'mrad', decimals=3) self.cm_pos_group = Group( @@ -485,8 +503,8 @@ class PositionsPanel(QWidget): # Monochromator self.mo1_bragg_angle = NumberIndicator('Bragg Angle', 'deg', decimals=3) - self.mo1_trx = NumberIndicator('TRX', 'mm', decimals=1) - self.mo1_try = NumberIndicator('TRY', 'mm', decimals=3) + self.mo1_trx = NumberIndicator('TRX', 'mm', decimals=2) + self.mo1_try = NumberIndicator('TRY', 'mm', decimals=2) self.mo1_pos_group = Group( 'Monochromator', [ @@ -497,7 +515,7 @@ class PositionsPanel(QWidget): ) # OP Slits 1 - self.sl1_centery = NumberIndicator('CENTERY', 'mm', decimals=1) + self.sl1_centery = NumberIndicator('CENTERY', 'mm', decimals=2) self.sl1_pos_group = Group( 'OP Slits 1', [ @@ -506,7 +524,7 @@ class PositionsPanel(QWidget): ) # OP Beam Monitor 1 - self.bm1_try = NumberIndicator('TRY', 'mm', decimals=1) + self.bm1_try = NumberIndicator('TRY', 'mm', decimals=2) self.bm1_pos_group = Group( 'OP Beam Monitor 1', [ @@ -515,8 +533,8 @@ class PositionsPanel(QWidget): ) # Focusing Mirror - self.fm_trx = NumberIndicator('TRX', 'mm', decimals=1) - self.fm_try = NumberIndicator('TRY', 'mm', decimals=3) + self.fm_trx = NumberIndicator('TRX', 'mm', decimals=2) + self.fm_try = NumberIndicator('TRY', 'mm', decimals=2) self.fm_bnd = NumberIndicator('BENDER', 'km', decimals=2) self.fm_rotx = NumberIndicator('PITCH', 'mrad', decimals=3) self.fm_pos_group = Group( @@ -530,7 +548,7 @@ class PositionsPanel(QWidget): ) # OP Slits 2 - self.sl2_centery = NumberIndicator('CENTERY', 'mm', decimals=1) + self.sl2_centery = NumberIndicator('CENTERY', 'mm', decimals=2) self.sl2_pos_group = Group( 'OP Slits 2', [ @@ -539,7 +557,7 @@ class PositionsPanel(QWidget): ) # OP Beam Monitor 2 - self.bm2_try = NumberIndicator('TRY', 'mm', decimals=1) + self.bm2_try = NumberIndicator('TRY', 'mm', decimals=2) self.bm2_pos_group = Group( 'OP Beam Monitor 2', [ @@ -548,7 +566,7 @@ class PositionsPanel(QWidget): ) # Optical Table - self.ot_try = NumberIndicator('TRY', 'mm', decimals=0) + self.ot_try = NumberIndicator('TRY', 'mm', decimals=2) self.ot_rotx = NumberIndicator('ROTX', 'mrad', decimals=3) self.ot_es1_trz = NumberIndicator('ES1 TRZ', 'mm', decimals=0) self.ot_pos_group = Group( @@ -585,7 +603,6 @@ class SurfacePlots(QWidget): def __init__(self, parent=None): super().__init__(parent) self._layout = QHBoxLayout(self) - # self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore self.surfaces = { 'assistant': { @@ -614,10 +631,11 @@ class SurfacePlots(QWidget): if theme == "light": self.color_impenetrable = (30, 30, 30) self.colors = [(79, 163, 224), (240, 128, 60)] + self.text_color = (255, 255, 255) else: # dark theme self.color_impenetrable = (220, 220, 220) self.colors = [(26, 111, 173), (212, 83, 10)] - + self.text_color = (0, 0, 0) # Create plot widgets for name, widget in self.plots.items(): @@ -634,8 +652,6 @@ class SurfacePlots(QWidget): plot_widget.setLabel('left', 'Z [mm]') plot_widget.setLabel('bottom', 'X [mm]') plot_widget.setMouseEnabled(x=False, y=False) - # plot_widget.setXRange(0, 25000, padding=0.1) - # plot_widget.setYRange(-20, 120, padding=0.1) plot_widget.setMenuEnabled(False) plot_widget.hideButtons() @@ -645,25 +661,26 @@ class SurfacePlots(QWidget): # Create surfaces for idx, scene in enumerate(self.surfaces): for name, device in self.surfaces[scene].items(): - brush = pg.mkBrush(color=self.colors[idx] + (150,)) + if scene in 'assistant': + brush = QBrush(QColor(*self.colors[idx], 255), Qt.DiagCrossPattern) + pen = pg.mkPen(QColor(*self.colors[idx], 255), width=1, style=Qt.DashLine) + z_value = 2 + else: + brush = QBrush(QColor(*self.colors[idx], 255)) + pen = pg.mkPen(QColor(*self.colors[idx], 255), width=1) + z_value = 1 widget = self.plots[name] self.plots[name][scene] = widget['widget'].plot( [], [], - pen=None, + pen=pen, name=scene, brush=brush, fillLevel=0, ) - self.plots[name][scene].setZValue(1) - - # self._layout.addStretch() - - logger.info(f'Created surfaces: {self.surfaces}') - logger.info(f'Created plots: {self.plots}') + self.plots[name][scene].setZValue(z_value) self.plot_walls() - # self.update_curves() def plot_walls(self): @@ -678,10 +695,10 @@ class SurfacePlots(QWidget): rect.setBrush(pg.QtGui.QBrush(pg.QtGui.QColor(*self.color_impenetrable))) # pylint: disable=E1101 rect.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) widget.addItem(rect) - text = pg.TextItem(sf, color='w', anchor=(0.5, 0.5)) # TODO: CHange color according to theme + text = pg.TextItem(sf, color=self.text_color, anchor=(0.5, 0.5)) # TODO: CHange color according to theme widget.addItem(text) text.setPos((hx+lx)/2, (hy+ly)/2) - text.setZValue(2) + text.setZValue(10) def plot_mono_surface(widget, xtal, xtalWidth, xtalOffsetX, xtalLength): for sf, w, offx, len in zip(xtal, xtalWidth, xtalOffsetX, xtalLength): @@ -694,10 +711,10 @@ class SurfacePlots(QWidget): rect.setBrush(pg.QtGui.QBrush(pg.QtGui.QColor(*self.color_impenetrable))) # pylint: disable=E1101 rect.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) widget.addItem(rect) - text = pg.TextItem(sf, color='w', anchor=(0.5, 0.5)) # TODO: CHange color according to theme + text = pg.TextItem(sf, color=self.text_color, anchor=(0.5, 0.5)) # TODO: CHange color according to theme widget.addItem(text) text.setPos(offx, 0) - text.setZValue(2) + text.setZValue(10) for name, plot in self.plots.items(): if name in 'cm': @@ -714,95 +731,6 @@ class SurfacePlots(QWidget): for name, plot in self.plots.items(): plot['widget'].disableAutoRange() - def impenetrable_color(self): - app = QApplication.instance() - theme = app.theme.theme # type: ignore - if theme == "light": - return (30, 30, 30) - else: - return (220, 220, 220) - - def golden_angle_color( - self, - colormap: str, - num: int, - format="QColor", - theme_offset=0.2, - theme=None, - ) -> list: - """ - Extract num colors from the specified colormap following golden angle distribution and return them in the specified format. - - Args: - colormap (str): Name of the colormap. - num (int): Number of requested colors. - format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor'). - theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background. - - Returns: - list: List of colors in the specified format. - - Raises: - ValueError: If theme_offset is not between 0 and 1. - """ - - cmap = pg.colormap.get(colormap) - phi = (1 + np.sqrt(5)) / 2 # Golden ratio - golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125 - - min_pos, max_pos = self.set_theme_offset(theme, theme_offset) - - # Generate positions within the acceptable range - positions = np.mod(np.arange(num) * golden_angle_conjugate, 1) - positions = min_pos + positions * (max_pos - min_pos) - - # Sample colors from the colormap at the calculated positions - colors = cmap.map(positions, mode="float") # type: ignore - color_list = [] - - for color in colors: # type: ignore - if format.upper() == "HEX": - color_list.append(QColor.fromRgbF(*color).name()) - elif format.upper() == "RGB": - color_list.append(tuple((np.array(color) * 255).astype(int))) - elif format.upper() == "QCOLOR": - color_list.append(QColor.fromRgbF(*color)) - else: - raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.") - return color_list - - def set_theme_offset(self, theme = None, offset=0.2) -> tuple: - """ - Set the theme offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background. - - Args: - theme(str): The theme to be applied. - offset(float): Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background. - - Returns: - tuple: Tuple of min_pos and max_pos. - - Raises: - ValueError: If theme_offset is not between 0 and 1. - """ - - if offset < 0 or offset > 1: - raise ValueError("theme_offset must be between 0 and 1") - - if theme is None: - app = QApplication.instance() - if hasattr(app, "theme"): - theme = app.theme.theme # type: ignore - - if theme == "light": - min_pos = 0.0 - max_pos = 1 - offset - else: - min_pos = 0.0 + offset - max_pos = 1.0 - - return min_pos, max_pos - def update_surfaces(self, scene, data): self.surfaces[scene] = data for name, device in self.surfaces[scene].items(): @@ -810,9 +738,6 @@ class SurfacePlots(QWidget): x = np.array(device['x'] + [device['x'][0]]) if len(device['x']) != 0 else np.array([]) y = np.array(device['y'] + [device['y'][0]]) if len(device['y']) != 0 else np.array([]) plot.setData(x=x, y=y) - # fill = pg.FillBetweenItem(curve, widget.plot(device['x'], np.zeros(len(device['x'])), pen=None), brush=pg.mkBrush('b')) - # widget.addItem(fill) - logger.info(self.surfaces) class SideviewPlot(QWidget): """Plot widget with two curves and legend.""" @@ -907,95 +832,6 @@ class SideviewPlot(QWidget): rect.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) self.plot_widget.addItem(rect) - def impenetrable_color(self): - app = QApplication.instance() - theme = app.theme.theme # type: ignore - if theme == "light": - return (30, 30, 30) - else: - return (220, 220, 220) - - def golden_angle_color( - self, - colormap: str, - num: int, - format="QColor", - theme_offset=0.2, - theme=None, - ) -> list: - """ - Extract num colors from the specified colormap following golden angle distribution and return them in the specified format. - - Args: - colormap (str): Name of the colormap. - num (int): Number of requested colors. - format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor'). - theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background. - - Returns: - list: List of colors in the specified format. - - Raises: - ValueError: If theme_offset is not between 0 and 1. - """ - - cmap = pg.colormap.get(colormap) - phi = (1 + np.sqrt(5)) / 2 # Golden ratio - golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125 - - min_pos, max_pos = self.set_theme_offset(theme, theme_offset) - - # Generate positions within the acceptable range - positions = np.mod(np.arange(num) * golden_angle_conjugate, 1) - positions = min_pos + positions * (max_pos - min_pos) - - # Sample colors from the colormap at the calculated positions - colors = cmap.map(positions, mode="float") # type: ignore - color_list = [] - - for color in colors: # type: ignore - if format.upper() == "HEX": - color_list.append(QColor.fromRgbF(*color).name()) - elif format.upper() == "RGB": - color_list.append(tuple((np.array(color) * 255).astype(int))) - elif format.upper() == "QCOLOR": - color_list.append(QColor.fromRgbF(*color)) - else: - raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.") - return color_list - - def set_theme_offset(self, theme = None, offset=0.2) -> tuple: - """ - Set the theme offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background. - - Args: - theme(str): The theme to be applied. - offset(float): Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background. - - Returns: - tuple: Tuple of min_pos and max_pos. - - Raises: - ValueError: If theme_offset is not between 0 and 1. - """ - - if offset < 0 or offset > 1: - raise ValueError("theme_offset must be between 0 and 1") - - if theme is None: - app = QApplication.instance() - if hasattr(app, "theme"): - theme = app.theme.theme # type: ignore - - if theme == "light": - min_pos = 0.0 - max_pos = 1 - offset - else: - min_pos = 0.0 + offset - max_pos = 1.0 - - return min_pos, max_pos - def update_curves(self): for idx, element in enumerate(self.data): self.curves[idx].setData( @@ -1004,8 +840,6 @@ class SideviewPlot(QWidget): ) -# --------------------------------------------------------- Standalone run --- - if __name__ == "__main__": from qtpy.QtWidgets import QApplication from bec_widgets.utils.bec_dispatcher import BECDispatcher diff --git a/debye_bec/bec_widgets/widgets/qt_widgets.py b/debye_bec/bec_widgets/widgets/qt_widgets.py index 8a9d1df..d9f3d6b 100644 --- a/debye_bec/bec_widgets/widgets/qt_widgets.py +++ b/debye_bec/bec_widgets/widgets/qt_widgets.py @@ -6,6 +6,7 @@ from qtpy.QtWidgets import ( QPushButton, QGroupBox, QComboBox, QApplication, QDoubleSpinBox ) from qtpy.QtGui import QFont +from qtpy.QtCore import Qt class Group(QGroupBox): def __init__(self, label, widgets): @@ -47,9 +48,12 @@ class NumberIndicator(QWidget): layout.setContentsMargins(10, 0, 0, 0) layout.setSpacing(0) self.label = QLabel(label) - self.label.setFixedWidth(150) + self.label.setFixedWidth(140) + self.label.setContentsMargins(0, 0, 10, 0) + self.label.setWordWrap(True) layout.addWidget(self.label) self.val = QLabel('-') + self.val.setAlignment(Qt.AlignTop) # type: ignore # self.val.setFixedWidth(140) layout.addWidget(self.val) self.unit = unit @@ -84,7 +88,9 @@ class InputTextField(QWidget): layout.setContentsMargins(10, 0, 0, 0) layout.setSpacing(0) self.label = QLabel(label) - self.label.setFixedWidth(150) + self.label.setFixedWidth(140) + self.label.setContentsMargins(0, 0, 10, 0) + self.label.setWordWrap(True) layout.addWidget(self.label) self.val = QLineEdit() self.val.setPlaceholderText('0') @@ -113,7 +119,9 @@ class InputNumberField(QWidget): layout.setContentsMargins(10, 0, 0, 0) layout.setSpacing(0) self.label = QLabel(label) - self.label.setFixedWidth(150) + self.label.setFixedWidth(140) + self.label.setContentsMargins(0, 0, 10, 0) + self.label.setWordWrap(True) layout.addWidget(self.label) self.val = QDoubleSpinBox() self.val.setRange(ll, hl) @@ -204,10 +212,11 @@ class ComboBox(QWidget): layout.setContentsMargins(10, 0, 0, 0) layout.setSpacing(0) self.label = QLabel(label) - self.label.setFixedWidth(150) + self.label.setFixedWidth(140) + self.label.setContentsMargins(0, 0, 10, 0) + self.label.setWordWrap(True) layout.addWidget(self.label) self.value = QComboBox() - # self.value.setFixedWidth(140) for entry in enums: self.value.addItem(entry) layout.addWidget(self.value) From 131d7f7f3ef3251959cd89e2f427ede1b41e88e8 Mon Sep 17 00:00:00 2001 From: x01da Date: Tue, 5 May 2026 13:38:56 +0200 Subject: [PATCH 38/54] Updated nexus structure --- .../file_writer/debye_nexus_structure.py | 144 +++++++++++++----- 1 file changed, 110 insertions(+), 34 deletions(-) diff --git a/debye_bec/file_writer/debye_nexus_structure.py b/debye_bec/file_writer/debye_nexus_structure.py index 33cf7b2..f2ff5c0 100644 --- a/debye_bec/file_writer/debye_nexus_structure.py +++ b/debye_bec/file_writer/debye_nexus_structure.py @@ -1,5 +1,6 @@ from bec_server.file_writer.default_writer import DefaultFormat +import debye_bec.bec_widgets.widgets.x01da_parameters as bl class DebyeNexusStructure(DefaultFormat): """Nexus Structure for Debye""" @@ -118,25 +119,35 @@ class DebyeNexusStructure(DefaultFormat): if "cm_rotx" in self.device_manager.devices: cm_incidence_angle = collimating_mirror.create_soft_link( - name="incidence angle", target="/entry/collection/devices/cm_rotx/cm_rotx/value" + name="incidence_angle", target="/entry/collection/devices/cm_rotx/cm_rotx/value" ) cm_incidence_angle.attrs["NX_class"] = "NX_FLOAT" cm_incidence_angle.attrs["units"] = "mrad" if "cm_roty" in self.device_manager.devices: cm_yaw_angle = collimating_mirror.create_soft_link( - name="yaw angle", target="/entry/collection/devices/cm_roty/cm_roty/value" + name="yaw_angle", target="/entry/collection/devices/cm_roty/cm_roty/value" ) cm_yaw_angle.attrs["NX_class"] = "NX_FLOAT" cm_yaw_angle.attrs["units"] = "mrad" if "cm_rotz" in self.device_manager.devices: cm_roll_angle = collimating_mirror.create_soft_link( - name="roll angle", target="/entry/collection/devices/cm_rotz/cm_rotz/value" + name="roll_angle", target="/entry/collection/devices/cm_rotz/cm_rotz/value" ) cm_roll_angle.attrs["NX_class"] = "NX_FLOAT" cm_roll_angle.attrs["units"] = "mrad" + if 'cm_trx' in self.device_manager.devices: + cm_trx = - self.device_manager.devices.cm_trx.read(cached=True).get('cm_trx').get('value') + stripe = 'Unknown' + for name, low, high in zip(bl.cm.surface, bl.cm.limOptX[0], bl.cm.limOptX[1]): + if low <= cm_trx <= high: + stripe = name + cm_stripe = collimating_mirror.create_dataset( + name="stripe", data=stripe + ) + cm_stripe.attrs["NX_class"] = "NX_CHAR" ################### ### fm mirror specific information @@ -160,26 +171,39 @@ class DebyeNexusStructure(DefaultFormat): if "fm_rotx" in self.device_manager.devices: fm_incidence_angle = focusing_mirror.create_soft_link( - name="incidence angle", target="/entry/collection/devices/fm_rotx/fm_rotx/value" + name="incidence_angle", target="/entry/collection/devices/fm_rotx/fm_rotx/value" ) fm_incidence_angle.attrs["NX_class"] = "NX_FLOAT" fm_incidence_angle.attrs["units"] = "mrad" if "fm_roty" in self.device_manager.devices: fm_yaw_angle = focusing_mirror.create_soft_link( - name="yaw angle", target="/entry/collection/devices/fm_roty/fm_roty/value" + name="yaw_angle", target="/entry/collection/devices/fm_roty/fm_roty/value" ) fm_yaw_angle.attrs["NX_class"] = "NX_FLOAT" fm_yaw_angle.attrs["units"] = "mrad" if "fm_rotz" in self.device_manager.devices: fm_roll_angle = focusing_mirror.create_soft_link( - name="roll angle", target="/entry/collection/devices/fm_rotz/fm_rotz/value" + name="roll_angle", target="/entry/collection/devices/fm_rotz/fm_rotz/value" ) fm_roll_angle.attrs["NX_class"] = "NX_FLOAT" fm_roll_angle.attrs["units"] = "mrad" - + if 'fm_trx' in self.device_manager.devices: + fm_trx = - self.device_manager.devices.fm_trx.read(cached=True).get('fm_trx').get('value') + stripe = 'Unknown' + for name, low, high in zip(bl.fm.surfaceFlat, bl.fm.limOptXFlat[1], bl.fm.limOptXFlat[0]): + if low <= fm_trx <= high: + stripe = name + ' (flat)' + for name, low, high in zip(bl.fm.surfaceToroid, bl.fm.limOptXToroid[1], bl.fm.limOptXToroid[0]): + if low <= fm_trx <= high: + stripe = name + ' (toroid)' + fm_stripe = focusing_mirror.create_dataset( + name="stripe", data=stripe + ) + fm_stripe.attrs["NX_class"] = "NX_CHAR" + ################### ## nidaq specific information ################### @@ -187,7 +211,44 @@ class DebyeNexusStructure(DefaultFormat): ## Logic if device exist if "nidaq" in self.device_manager.devices: - #ai_chans_bit = self.device_manager.devices.nidaq.ai_chans.read().get("nidaq_ai_chans").get("value") + #ai_chans_bits = self.device_manager.devices.nidaq.ai_chans.read(cached=True).get("nidaq_ai_chans").get("value") + ai_chans_bits = self.configuration.get("nidaq", {}).get("nidaq_ai_chans", {}).get("value") + ci_chans_bits = self.configuration.get("nidaq", {}).get("nidaq_ci_chans", {}).get("value") + #add_chans_bits = self.device_manager.devices.nidaq.add_chans.read(cached=True).get("nidaq_add_chans").get("value") + add_chans_bits = self.configuration.get("nidaq", {}).get("nidaq_add_chans", {}).get("value") + + measurement_mode = entry.create_group(name="mode") + measurement_mode.attrs["NX_class"] = "NX_CHAR" + + if (int(ci_chans_bits) & 0x7F) != 0: + # Create a dataset + rayspec_sdd_active = measurement_mode.create_group(name="Multi_Element_Partial_Fluorescence_Yield") + me_sdd = rayspec_sdd_active.create_dataset(name="Detector", data="Rayspec 7 element Silicon Drift Detector") + me_sdd.attrs["NX_class"] = "NX_CHAR" + + if (int(ci_chans_bits) & (1<<8)) != 0: + # Create a dataset + ketek_sdd_active = measurement_mode.create_group(name="Single_Element_Partial_Fluorescence_Yield") + se_sdd = ketek_sdd_active.create_dataset(name="Detector", data="Ketex mini single element Silicon Drift Detector") + se_sdd.attrs["NX_class"] = "NX_CHAR" + + if ((int(ai_chans_bits) & (1<<6)) != 0): + # Create a dataset + pips_active = measurement_mode.create_group(name="Total_Flourescence_Yield") + tfy = pips_active.create_dataset(name="Detector", data="Mirion Technologies Partially Depeleted PIPS Detector") + tfy.attrs["NX_class"] = "NX_CHAR" + + if ((int(ai_chans_bits) & (1<<0)) != 0) & ((int(ai_chans_bits) & (1<<2)) != 0): + # Create a dataset + ai0ai2_active = measurement_mode.create_group(name="Sample_Transmission") + sam_trans = ai0ai2_active.create_dataset(name="Detector", data="Ionitec 15 cm gas filled Ionisation Chambers") + sam_trans.attrs["NX_class"] = "NX_CHAR" + + if ((int(ai_chans_bits) & (1<<2)) != 0) & ((int(ai_chans_bits) & (1<<4)) != 0): + # Create a dataset + ai2ai4_active = measurement_mode.create_group(name="Reference_Transmission") + ref_trans = ai2ai4_active.create_dataset(name="Detector", data="Ionitec 15 cm gas filled Ionisation Chambers") + ref_trans.attrs["NX_class"] = "NX_CHAR" main_data = entry.create_group(name="data") main_data.attrs["NX_class"] = "NXdata" @@ -203,53 +264,68 @@ class DebyeNexusStructure(DefaultFormat): main_data.create_soft_link(name="energy", target="/entry/collection/readout_groups/async/nidaq/nidaq_energy/value") ################## - ## i0, test whether the signal exists. how to check from config? + ## i0 ################### - #if (int(ai_chans_bit) & (1<<0)) !=0: - i0 = main_data.create_group(name="i0") - i0.attrs["NX_class"] = "NXdata" - i0.attrs["units"] = "V" + if (int(ai_chans_bits) & (1<<0)) !=0: + i0 = main_data.create_group(name="i0") + i0.attrs["NX_class"] = "NXdata" + i0.attrs["units"] = "V" - main_data.create_soft_link(name="i0", target="/entry/collection/readout_groups/async/nidaq/nidaq_ai0_mean/value") + main_data.create_soft_link(name="i0", target="/entry/collection/readout_groups/async/nidaq/nidaq_ai0_mean/value") ################## - ## i1, test whether the signal exists. how to check from config? + ## i1 ################### - i1 = main_data.create_group(name="i1") - i1.attrs["NX_class"] = "NXdata" - i1.attrs["units"] = "V" - - main_data.create_soft_link(name="i1", target="/entry/collection/readout_groups/async/nidaq/nidaq_ai2_mean/value") + if (int(ai_chans_bits) & (1<<2)) !=0: + i1 = main_data.create_group(name="i1") + i1.attrs["NX_class"] = "NXdata" + i1.attrs["units"] = "V" + + main_data.create_soft_link(name="i1", target="/entry/collection/readout_groups/async/nidaq/nidaq_ai2_mean/value") ################## - ## i2, test whether the signal exists. how to check from config? + ## i2 ################### - - i2 = main_data.create_group(name="i2") - i2.attrs["NX_class"] = "NXdata" - i2.attrs["units"] = "V" - - main_data.create_soft_link(name="i2", target="/entry/collection/readout_groups/async/nidaq/nidaq_ai4_mean/value") + + if (int(ai_chans_bits) & (1<<4)) !=0: + i2 = main_data.create_group(name="i2") + i2.attrs["NX_class"] = "NXdata" + i2.attrs["units"] = "V" + + main_data.create_soft_link(name="i2", target="/entry/collection/readout_groups/async/nidaq/nidaq_ai4_mean/value") + + ################## + ## ci sum + ################### + + if int(ci_chans_bits) > 0: + ci_sum = main_data.create_group(name="Fluorescence_Sum") + ci_sum.attrs["NX_class"] = "NXdata" + ci_sum.attrs["units"] = "counts" + + main_data.create_soft_link(name="Fluorescence_Sum", target="/entry/collection/readout_groups/async/nidaq/nidaq_cisum/value") ################## ## mu sample, test whether the signal exists. how to check from config? ################### + + if (int(add_chans_bits) & (1<<0)) !=0: + mu_sample = main_data.create_group(name="mu_sample") + mu_sample.attrs["NX_class"] = "NXdata" - mu_sample = main_data.create_group(name="mu_sample") - mu_sample.attrs["NX_class"] = "NXdata" - - main_data.create_soft_link(name="mu_sample", target="/entry/collection/readout_groups/async/nidaq/nidaq_smpl_abs/value") + main_data.create_soft_link(name="mu_sample", target="/entry/collection/readout_groups/async/nidaq/nidaq_smpl_abs/value") ################## ## mu reference, test whether the signal exists. how to check from config? ################### - mu_reference = main_data.create_group(name="mu_reference") - mu_reference.attrs["NX_class"] = "NXdata" + if (int(add_chans_bits) & (1<<1)) !=0: + mu_reference = main_data.create_group(name="mu_reference") + mu_reference.attrs["NX_class"] = "NXdata" - main_data.create_soft_link(name="mu_reference", target="/entry/collection/readout_groups/async/nidaq/nidaq_ref_abs/value") + main_data.create_soft_link(name="mu_reference", target="/entry/collection/readout_groups/async/nidaq/nidaq_ref_abs/value") From 3d2485aea77872aa82126cda36855326a66e9fb9 Mon Sep 17 00:00:00 2001 From: x01da Date: Tue, 5 May 2026 15:32:01 +0200 Subject: [PATCH 39/54] wip: digital twin --- .../widgets/digital_twin/calc_surfaces.py | 2 +- .../digital_twin/calculate_positions.py | 2 +- .../digital_twin/calculate_sideview.py | 2 +- .../widgets/digital_twin/digital_twin.py | 261 +++++++++++------- debye_bec/bec_widgets/widgets/qt_widgets.py | 256 ++++++++--------- .../{digital_twin => }/x01da_parameters.py | 0 6 files changed, 274 insertions(+), 249 deletions(-) rename debye_bec/bec_widgets/widgets/{digital_twin => }/x01da_parameters.py (100%) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py b/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py index 25a129b..f5d408a 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py @@ -4,7 +4,7 @@ import numpy as np from bec_lib import bec_logger os.environ["USE_XRT"] = "False" -import debye_bec.bec_widgets.widgets.digital_twin.x01da_parameters as bl +import debye_bec.bec_widgets.widgets.x01da_parameters as bl def calc_surfaces(cfg): diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py b/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py index eda5698..8012baf 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py @@ -3,7 +3,7 @@ import numpy as np from bec_lib import bec_logger os.environ["USE_XRT"] = "False" -import debye_bec.bec_widgets.widgets.digital_twin.x01da_parameters as bl +import debye_bec.bec_widgets.widgets.x01da_parameters as bl logger = bec_logger.logger diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calculate_sideview.py b/debye_bec/bec_widgets/widgets/digital_twin/calculate_sideview.py index 6312a09..ce97a31 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calculate_sideview.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calculate_sideview.py @@ -1,5 +1,5 @@ import numpy as np -import debye_bec.bec_widgets.widgets.digital_twin.x01da_parameters as bl +import debye_bec.bec_widgets.widgets.x01da_parameters as bl def calc_sideview(cfg): diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 1ae45a1..b3a9846 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -1,40 +1,54 @@ +""" +Digital Twin: Custom BEC widget to support the beamline alignment. +""" + import sys import re -import datetime import numpy as np from scipy.interpolate import UnivariateSpline +from xrt.backends.raycing.physconsts import CHeVcm, AVOGADRO from bec_lib import bec_logger + # pylint: disable=E0611 from qtpy.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QApplication, QLayout, QGroupBox + QWidget, + QVBoxLayout, + QHBoxLayout, + QApplication, + QLayout, ) # pylint: disable=E0611 -from qtpy.QtCore import Qt, QTimer -from qtpy.QtGui import QColor, QFont, QBrush +from qtpy.QtCore import ( + Qt, + QTimer, +) +from qtpy.QtGui import ( + QColor, + QBrush, +) import pyqtgraph as pg -from xrt.backends.raycing.physconsts import CHeVcm, AVOGADRO - from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot -from debye_bec.bec_widgets.widgets.qt_widgets import InputNumberField, ComboBox, Group, NumberIndicator - +from debye_bec.bec_widgets.widgets.qt_widgets import ( + InputNumberField, + ComboBox, + Group, + NumberIndicator, + Mover, +) from debye_bec.bec_widgets.widgets.digital_twin.calculate_positions import calc_positions from debye_bec.bec_widgets.widgets.digital_twin.calculate_sideview import calc_sideview from debye_bec.bec_widgets.widgets.digital_twin.calc_surfaces import calc_surfaces -import debye_bec.bec_widgets.widgets.digital_twin.x01da_parameters as bl +import debye_bec.bec_widgets.widgets.x01da_parameters as bl logger = bec_logger.logger class DigitalTwin(BECWidget, QWidget): """ - A simple BEC widget with: - - Two numeric inputs (A, B) - - Two computed outputs (Sum, Product) - - A live plot that updates every second + Main widget of Digital Twin """ PLUGIN = True @@ -53,65 +67,94 @@ class DigitalTwin(BECWidget, QWidget): self.sideview_plot = SideviewPlot() self.surface_plots = SurfacePlots() self.positions = PositionsPanel() + # self.mover = MoverPanel(dev=self.dev) self.root_layout.addWidget(self.input, stretch=1, alignment=Qt.AlignTop) # type: ignore self.plot_layout.addWidget(self.sideview_plot) # type: ignore self.plot_layout.addWidget(self.surface_plots) # type: ignore self.root_layout.addWidget(self.plot_widget, stretch=1, alignment=Qt.AlignTop) # type: ignore self.root_layout.addWidget(self.positions, stretch=1, alignment=Qt.AlignTop) # type: ignore + # self.root_layout.addWidget(self.mover, stretch=1, alignment=Qt.AlignTop) # type: ignore self.setLayout(self.root_layout) self.setWindowTitle("Digital Twin") self.resize(1800, 800) - self.input.energy.value_changed_connect(self.calc_bragg_angle) - self.input.sldi_hacc.value_changed_connect(self.calc_positions) - self.input.sldi_vacc.value_changed_connect(self.calc_positions) - self.input.cm_stripe.activated_connect(self.calc_positions) - self.input.cm_pitch.value_changed_connect(self.calc_positions) - self.input.mo1_mode.activated_connect(self.calc_positions) - self.input.fm_stripe.activated_connect(self.calc_positions) - self.input.fm_pitch.value_changed_connect(self.calc_positions) - self.input.smpl.value_changed_connect(self.calc_positions) - - self.input.energy.value_changed_connect(self.calc_crit_angle) - self.input.cm_stripe.activated_connect(self.calc_crit_angle) - - self.input.mo1_xtal.activated_connect(self.calc_bragg_angle) - - self.input.mo1_mode.activated_connect(self.update_mono_mode) - - self.input.fm_stripe.activated_connect(self.calc_ideal_fm_pitch) - self.input.smpl.value_changed_connect(self.calc_ideal_fm_pitch) - - self.input.energy.value_changed_connect(self.calc_cm_reflectivity) - self.input.cm_pitch.value_changed_connect(self.calc_cm_reflectivity) - self.input.cm_stripe.activated_connect(self.calc_cm_reflectivity) - self.input.fm_pitch.value_changed_connect(self.calc_fm_reflectivity) - self.input.fm_stripe.activated_connect(self.calc_fm_reflectivity) - self.input.energy.value_changed_connect(self.calc_cm_reflectivity) - - self.input.energy.value_changed_connect(self.calc_energy_resolution) - self.input.mo1_xtal.activated_connect(self.calc_energy_resolution) + self.input.energy.value_changed_connect(self.calc_assistant) + self.input.sldi_hacc.value_changed_connect(self.calc_assistant) + self.input.sldi_vacc.value_changed_connect(self.calc_assistant) + self.input.cm_stripe.activated_connect(self.calc_assistant) + self.input.cm_pitch.value_changed_connect(self.calc_assistant) + self.input.mo1_mode.activated_connect(self.calc_assistant) + self.input.mo1_xtal.activated_connect(self.calc_assistant) + self.input.fm_stripe.activated_connect(self.calc_assistant) + self.input.fm_pitch.value_changed_connect(self.calc_assistant) + self.input.smpl.value_changed_connect(self.calc_assistant) self.bragg_angle = 0 - self.calc_bragg_angle() - self.calc_ideal_fm_pitch() - self.calc_crit_angle() - self.calc_assistant_sideview() - self.calc_reality_sideview() - self.calc_cm_reflectivity() - self.calc_fm_reflectivity() - self.calc_energy_resolution() + + # Initialize all values + self.calc_assistant(identifier='init') # Timer: update plot every 1 second self._timer = QTimer(self) self._timer.setInterval(1000) - self._timer.timeout.connect(self.calc_reality_sideview) + self._timer.timeout.connect(self.calc_reality) # TODO: Check if I need to stop the timer if the widget is closed? self._timer.start() - def calc_energy_resolution(self, *args): + @SafeSlot() + def calc_assistant(self, *args, **kwargs): + identifier = kwargs['identifier'] + match identifier: + case 'init': + self.calc_mo1_bragg_angle() + self.calc_cm_crit_pitch() + self.calc_cm_reflectivity() + self.calc_fm_reflectivity() + self.calc_cm_fm_harm_suppr() + self.calc_fm_ideal_pitch() + self.calc_mo1_energy_resolution() + case 'energy': + self.calc_mo1_bragg_angle() + self.calc_cm_crit_pitch() + self.calc_cm_reflectivity() + self.calc_fm_reflectivity() + self.calc_cm_fm_harm_suppr() + self.calc_mo1_energy_resolution() + case 'cm_stripe': + self.calc_cm_crit_pitch() + self.calc_cm_reflectivity() + self.calc_cm_fm_harm_suppr() + case 'cm_pitch': + self.calc_cm_reflectivity() + self.calc_cm_fm_harm_suppr() + case 'mo1_mode': + self.update_mo1_mode() + case 'mo1_xtal': + self.calc_mo1_bragg_angle() + self.calc_mo1_energy_resolution() + case 'fm_stripe': + self.calc_fm_reflectivity() + self.calc_cm_fm_harm_suppr() + self.calc_fm_ideal_pitch() + case 'smpl': + self.calc_fm_ideal_pitch() + self.calc_positions() + self.calc_assistant_sideview() + self.calc_assistant_surfaces() + + @SafeSlot() + def calc_reality(self): + config = self.get_reality_config() + beam = calc_sideview(config) + self.sideview_plot.data['reality']['x'] = beam['Z'] + self.sideview_plot.data['reality']['y'] = beam['Y'] + self.sideview_plot.update_curves() + surfaces = calc_surfaces(config) + self.surface_plots.update_surfaces(scene='reality', data=surfaces) + + def calc_mo1_energy_resolution(self, *args, **kwargs): xtal = self.input.mo1_xtal.currentText().translate(str.maketrans('', '', '()')) # Remove brackets from xtal name to conform with parameters index = bl.mo1.xtal.index(xtal) crystal = bl.mo1.material1[index] @@ -139,7 +182,7 @@ class DigitalTwin(BECWidget, QWidget): self.input.mo1_eres.setValue(dE) - def calc_cm_reflectivity(self, *args): + def calc_cm_reflectivity(self): index = bl.cm.surface.index(self.input.cm_stripe.currentText()) rs, rp = bl.cm.material[index].get_amplitude( self.input.energy.value(), @@ -154,11 +197,7 @@ class DigitalTwin(BECWidget, QWidget): self.input.cm_refl_harm.setValue(100 * abs(rs)**2) self.input.cm_refl_harm.setLabel(f"Reflectivity at \n{3 * self.input.energy.value():.0f} eV") - harm_suppr = (self.input.cm_refl.value() * self.input.fm_refl.value()) / (self.input.cm_refl_harm.value() * self.input.fm_refl_harm.value()) - self.input.cm_fm_harm_suppr.setValue(harm_suppr) - self.input.cm_fm_harm_suppr.setLabel(f"Total Suppression Factor at {3 * self.input.energy.value():.0f} eV") - - def calc_fm_reflectivity(self, *args): + def calc_fm_reflectivity(self): if self.input.fm_stripe.currentText() in ('Rh (toroid)', 'Pt (toroid)'): surface = bl.fm.surfaceToroid material = bl.fm.materialToroid @@ -182,6 +221,7 @@ class DigitalTwin(BECWidget, QWidget): self.input.fm_refl_harm.setValue(100 * abs(rs)**2) self.input.fm_refl_harm.setLabel(f"Reflectivity at \n{3 * self.input.energy.value():.0f} eV") + def calc_cm_fm_harm_suppr(self): harm_suppr = (self.input.cm_refl.value() * self.input.fm_refl.value()) / (self.input.cm_refl_harm.value() * self.input.fm_refl_harm.value()) self.input.cm_fm_harm_suppr.setValue(harm_suppr) self.input.cm_fm_harm_suppr.setLabel(f"Total Suppression Factor at {3 * self.input.energy.value():.0f} eV") @@ -203,8 +243,7 @@ class DigitalTwin(BECWidget, QWidget): } # logger.info(f'Config created: {config}') return config - - # TODO Needs to run in a loop in a separate thread due to the long time it takes to get the values from self.dev... + def get_reality_config(self): if abs(self.dev.mo1_trx.read()['mo1_trx']['value']) > 5: mo1_mode = 'Monochromatic' @@ -221,6 +260,7 @@ class DigitalTwin(BECWidget, QWidget): for name, low, high in zip(bl.cm.surface, bl.cm.limOptX[0], bl.cm.limOptX[1]): if low <= cm_trx <= high: cm_stripe = name + cm_pitch = -self.dev.cm_rotx.read()['cm_rotx']['value'] * 1e-3 fm_trx = -self.dev.fm_trx.read()['fm_trx']['value'] fm_stripe = None for name, low, high in zip(bl.fm.surfaceFlat, bl.fm.limOptXFlat[1], bl.fm.limOptXFlat[0]): @@ -229,16 +269,18 @@ class DigitalTwin(BECWidget, QWidget): for name, low, high in zip(bl.fm.surfaceToroid, bl.fm.limOptXToroid[1], bl.fm.limOptXToroid[0]): if low <= fm_trx <= high: fm_stripe = name + ' (toroid)' + fm_pitch = -self.dev.fm_rotx.read()['fm_rotx']['value'] * 1e-3 + fm_pitch_real = 2 * cm_pitch - fm_pitch config = { # Config in SI units! 'energy' : mo1_bragg['mo1_bragg']['value'], 'h_acc' : h_acc, 'v_acc' : v_acc, - 'cm_pitch' : -self.dev.cm_rotx.read()['cm_rotx']['value'] * 1e-3, + 'cm_pitch' : cm_pitch, 'cm_stripe' : cm_stripe, 'mo1_mode' : mo1_mode, 'mo1_xtal' : mo1_bragg['mo1_bragg_crystal_current_xtal_string']['value'], 'mo1_bragg' : mo1_bragg['mo1_bragg_angle']['value']/180*np.pi, - 'fm_pitch' : -self.dev.fm_rotx.read()['fm_rotx']['value'] * 1e-3, + 'fm_pitch' : fm_pitch_real, 'fm_stripe' : fm_stripe, 'fm_gain_height' : 1, 'smpl' : self.dev.ot_es1_trz.read()['ot_es1_trz']['value'], @@ -247,35 +289,19 @@ class DigitalTwin(BECWidget, QWidget): return config @SafeSlot() - def calc_assistant_sideview(self, *args): + def calc_assistant_sideview(self): beam = calc_sideview(self.get_assistant_config()) self.sideview_plot.data['assistant']['x'] = beam['Z'] self.sideview_plot.data['assistant']['y'] = beam['Y'] self.sideview_plot.update_curves() @SafeSlot() - def calc_reality_sideview(self): - # logger.info('Update reality plot') - beam = calc_sideview(self.get_reality_config()) - self.sideview_plot.data['reality']['x'] = beam['Z'] - self.sideview_plot.data['reality']['y'] = beam['Y'] - self.sideview_plot.update_curves() - - # TODO Move to different place - self.calc_reality_surfaces() - - @SafeSlot() - def calc_assistant_surfaces(self, *args): + def calc_assistant_surfaces(self): surfaces = calc_surfaces(self.get_assistant_config()) self.surface_plots.update_surfaces(scene='assistant', data=surfaces) @SafeSlot() - def calc_reality_surfaces(self, *args): - surfaces = calc_surfaces(self.get_reality_config()) - self.surface_plots.update_surfaces(scene='reality', data=surfaces) - - @SafeSlot() - def calc_positions(self, *args): + def calc_positions(self): out = calc_positions(self.get_assistant_config()) self.positions.sldi_gapx.setValue(out['sldi_gapx']['value']) @@ -299,12 +325,8 @@ class DigitalTwin(BECWidget, QWidget): self.positions.ot_rotx.setValue(out['ot_rotx']['value']) self.positions.ot_es1_trz.setValue(out['ot_es1_trz']['value']) - # TODO move to somewhere else! - self.calc_assistant_sideview() - self.calc_assistant_surfaces() - @SafeSlot() - def calc_bragg_angle(self, *args): + def calc_mo1_bragg_angle(self): """ Calculates bragg angle in rad """ @@ -335,17 +357,17 @@ class DigitalTwin(BECWidget, QWidget): bragg_angle_cor = (2 * cm_pitch) / np.pi * 180 self.input.mo1_bragg_angle.setValue(bragg_angle_cor) - self.calc_positions() + # self.calc_positions() @SafeSlot() - def update_mono_mode(self, *args): + def update_mo1_mode(self): if self.input.mo1_mode.currentText() in 'Monochromatic': self.input.mo1_xtal.setDisabled(False) else: self.input.mo1_xtal.setDisabled(True) @SafeSlot() - def calc_ideal_fm_pitch(self, *args): + def calc_fm_ideal_pitch(self): p = bl.fm.center[1] # posFM q = self.input.smpl.value() - bl.fm.center[1] # dist posFM to posEX f = (p * q) / (p + q) # focal length @@ -357,7 +379,7 @@ class DigitalTwin(BECWidget, QWidget): self.input.fm_pitch_ideal.setValue(-pitch * 1e3) @SafeSlot() - def calc_crit_angle(self, *args): + def calc_cm_crit_pitch(self): stripe = self.input.cm_stripe.currentText() # Config Mirror if stripe in 'Si': @@ -384,11 +406,11 @@ class InputPanel(QWidget): self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore # Energy - self.energy = InputNumberField('Energy [eV]', init=8979, decimals=0, single_step=100, ll=4000, hl=65000) + self.energy = InputNumberField('energy', 'Energy [eV]', init=8979, decimals=0, single_step=100, ll=4000, hl=65000) # FE Slits Acceptance - self.sldi_hacc = InputNumberField('Horizontal [± mrad]', init=0.25, decimals=2, single_step=0.01, ll=-0.1, hl=0.9) - self.sldi_vacc = InputNumberField('Vertical [± mrad]', init=0.1, decimals=2, single_step=0.01, ll=-0.1, hl=0.5) + self.sldi_hacc = InputNumberField('h_acc', 'Horizontal [± mrad]', init=0.25, decimals=2, single_step=0.01, ll=-0.1, hl=0.9) + self.sldi_vacc = InputNumberField('v_acc', 'Vertical [± mrad]', init=0.1, decimals=2, single_step=0.01, ll=-0.1, hl=0.5) self.sldi_ass_group = Group( 'FE Slits Acceptance', [ @@ -398,9 +420,9 @@ class InputPanel(QWidget): ) # Collimating mirror - self.cm_stripe = ComboBox('Stripe', ['Si', 'Rh', 'Pt']) + self.cm_stripe = ComboBox('cm_stripe', 'Stripe', ['Si', 'Rh', 'Pt']) self.cm_pitch_critical = NumberIndicator('Critical Pitch', 'mrad', decimals=3) - self.cm_pitch = InputNumberField('Pitch [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-4.6, hl=-1.2) + self.cm_pitch = InputNumberField('cm_pitch', 'Pitch [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-4.6, hl=-1.2) self.cm_refl = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.cm_refl_harm = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.cm_ass_group = Group( @@ -415,8 +437,8 @@ class InputPanel(QWidget): ) # Monochromator - self.mo1_mode = ComboBox('Mode', ['Monochromatic', 'Pinkbeam']) - self.mo1_xtal = ComboBox('Crystal', ['Si(111)', 'Si(311)']) + self.mo1_mode = ComboBox('mo1_mode', 'Mode', ['Monochromatic', 'Pinkbeam']) + self.mo1_xtal = ComboBox('mo1_xtal', 'Crystal', ['Si(111)', 'Si(311)']) self.mo1_bragg_angle = NumberIndicator('Bragg Angle', 'deg', decimals=1) self.mo1_eres = NumberIndicator('Energy Resolution', 'eV', decimals=2) self.mo1_ass_group = Group( @@ -430,9 +452,9 @@ class InputPanel(QWidget): ) # Focusing Mirror - self.fm_stripe = ComboBox('Stripe', ['Rh (toroid)', 'Rh (flat)', 'Pt (toroid)', 'Pt (flat)']) + self.fm_stripe = ComboBox('fm_stripe', 'Stripe', ['Rh (toroid)', 'Rh (flat)', 'Pt (toroid)', 'Pt (flat)']) self.fm_pitch_ideal = NumberIndicator('Ideal Pitch', 'mrad', decimals=3) - self.fm_pitch = InputNumberField('Pitch [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-10, hl=2) + self.fm_pitch = InputNumberField('fm_pitch', 'Pitch [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-10, hl=2) self.fm_refl = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.fm_refl_harm = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.fm_ass_group = Group( @@ -448,7 +470,7 @@ class InputPanel(QWidget): # Sample self.cm_fm_harm_suppr = NumberIndicator('Total Suppression Factor at x eV', '', decimals=0) - self.smpl = InputNumberField('Sample Position [mm]', init=23511, decimals=0, single_step=100, ll=23000, hl=30000) + self.smpl = InputNumberField('smpl', 'Sample Position [mm]', init=23511, decimals=0, single_step=100, ll=23000, hl=30000) # Assemble complete assitant group self.input_group = Group( @@ -597,6 +619,43 @@ class PositionsPanel(QWidget): self._layout .addWidget(self.position_group) self._layout .addStretch() +class MoverPanel(QWidget): + + def __init__(self, dev, parent=None): + super().__init__(parent) + self.dev = dev + self._layout = QVBoxLayout(self) + self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore + + mot = self.dev.sldi_gapx + egu = self.dev.sldi_gapx.egu() + prec = self.dev.sldi_gapx.precision + self.sldi_gapx = Mover(mot, egu, prec) + + mot = self.dev.sldi_gapy + egu = self.dev.sldi_gapy.egu() + prec = self.dev.sldi_gapy.precision + self.sldi_gapy = Mover(mot, egu, prec) + + self.sldi_mov_group = Group( + 'FE Slits', + [ + self.sldi_gapx, + self.sldi_gapy, + ] + ) + + # Assemble complete assitant group + self.mover_group = Group( + 'Mover', + [ + self.sldi_mov_group, + ] + ) + + self._layout .addWidget(self.mover_group) + self._layout .addStretch() + class SurfacePlots(QWidget): """Plot widget with two curves and legend.""" diff --git a/debye_bec/bec_widgets/widgets/qt_widgets.py b/debye_bec/bec_widgets/widgets/qt_widgets.py index d9f3d6b..3791faf 100644 --- a/debye_bec/bec_widgets/widgets/qt_widgets.py +++ b/debye_bec/bec_widgets/widgets/qt_widgets.py @@ -42,7 +42,7 @@ class Group(QGroupBox): # self.value.setText(text) class NumberIndicator(QWidget): - def __init__(self, label, unit=None, highlight=False, decimals=3): + def __init__(self, label='', unit=None, highlight=False, decimals=3): super().__init__() layout = QHBoxLayout(self) layout.setContentsMargins(10, 0, 0, 0) @@ -80,44 +80,45 @@ class NumberIndicator(QWidget): text = text + ' ' + self.unit self.val.setText(text) -class InputTextField(QWidget): - def __init__(self, topic, label): - super().__init__() - self.topic = topic - layout = QHBoxLayout(self) - layout.setContentsMargins(10, 0, 0, 0) - layout.setSpacing(0) - self.label = QLabel(label) - self.label.setFixedWidth(140) - self.label.setContentsMargins(0, 0, 10, 0) - self.label.setWordWrap(True) - layout.addWidget(self.label) - self.val = QLineEdit() - self.val.setPlaceholderText('0') - # self.val.setFixedWidth(140) - layout.addWidget(self.val) +# class InputTextField(QWidget): +# def __init__(self, topic, label): +# super().__init__() +# self.topic = topic +# layout = QHBoxLayout(self) +# layout.setContentsMargins(10, 0, 0, 0) +# layout.setSpacing(0) +# self.label = QLabel(label) +# self.label.setFixedWidth(140) +# self.label.setContentsMargins(0, 0, 10, 0) +# self.label.setWordWrap(True) +# layout.addWidget(self.label) +# self.val = QLineEdit() +# self.val.setPlaceholderText('0') +# # self.val.setFixedWidth(140) +# layout.addWidget(self.val) - def set_text(self, text): - self.val.setText(text) +# def set_text(self, text): +# self.val.setText(text) - def has_focus(self) -> bool: - return self.val.hasFocus() +# def has_focus(self) -> bool: +# return self.val.hasFocus() - def text(self) -> str: - return self.val.text() +# def text(self) -> str: +# return self.val.text() - def set_on_return(self, func): - """Connect a function to the Enter/Return key press.""" - self.val.returnPressed.connect( - partial(func, self.val, self.topic, lambda: self.val.text()) - ) +# def set_on_return(self, func): +# """Connect a function to the Enter/Return key press.""" +# self.val.returnPressed.connect( +# partial(func, self.val, self.topic, lambda: self.val.text()) +# ) class InputNumberField(QWidget): - def __init__(self, label, init=0.0, decimals=1, single_step=0.1, ll=-1e6, hl=1e6): + def __init__(self, identifier='', label='', init=0.0, decimals=1, single_step=0.1, ll=-1e6, hl=1e6): super().__init__() layout = QHBoxLayout(self) layout.setContentsMargins(10, 0, 0, 0) layout.setSpacing(0) + self.identifier = identifier self.label = QLabel(label) self.label.setFixedWidth(140) self.label.setContentsMargins(0, 0, 10, 0) @@ -143,74 +144,16 @@ class InputNumberField(QWidget): def value_changed_connect(self, func): """Connect a function to the Enter/Return key press.""" self.val.valueChanged.connect( - partial(func, self.val, lambda: self.val.value()) + partial(func, identifier=self.identifier, value_obj=self.val, value=lambda: self.val.value()) ) -# class IPAdressInputField(QWidget): -# def __init__(self, topic, label): -# super().__init__() -# self.topic = topic -# layout = QHBoxLayout(self) -# layout.setContentsMargins(10, 0, 0, 0) -# layout.setSpacing(0) -# self.label = QLabel(label) -# self.label.setFixedWidth(150) -# layout.addWidget(self.label) -# self.oct0 = QLineEdit() -# self.oct0.setPlaceholderText('0') -# self.oct0.setFixedWidth(30) -# layout.addWidget(self.oct0) -# separator1 = QLabel('.') -# layout.addWidget(separator1) -# self.oct1 = QLineEdit() -# self.oct1.setPlaceholderText('0') -# self.oct1.setFixedWidth(30) -# layout.addWidget(self.oct1) -# separator2 = QLabel('.') -# layout.addWidget(separator2) -# self.oct2 = QLineEdit() -# self.oct2.setPlaceholderText('0') -# self.oct2.setFixedWidth(30) -# layout.addWidget(self.oct2) -# separator3 = QLabel('.') -# layout.addWidget(separator3) -# self.oct3 = QLineEdit() -# self.oct3.setPlaceholderText('0') -# self.oct3.setFixedWidth(30) -# layout.addWidget(self.oct3) - -# self.oct0.editingFinished.connect(partial(self.check_octet, self.oct0)) -# self.oct1.editingFinished.connect(partial(self.check_octet, self.oct1)) -# self.oct2.editingFinished.connect(partial(self.check_octet, self.oct2)) -# self.oct3.editingFinished.connect(partial(self.check_octet, self.oct3)) - -# def check_octet(self, octet): -# if octet.text().isnumeric(): -# if int(octet.text()) < 0: -# octet.setText('0') -# if int(octet.text()) > 254: -# octet.setText('254') -# else: -# octet.setText('') - -# def get_ip(self): -# return f'{self.oct0.text()}.{self.oct1.text()}.{self.oct2.text()}.{self.oct3.text()}' - -# def set_ip(self, ip): -# octets = ip.split('.') -# if len(octets) == 4 and all(octet.isnumeric() for octet in octets): -# if all(int(octet) > 0 and int(octet) < 254 for octet in octets): -# self.oct0.setText(octets[0]) -# self.oct1.setText(octets[1]) -# self.oct2.setText(octets[2]) -# self.oct3.setText(octets[3]) - class ComboBox(QWidget): - def __init__(self, label, enums): + def __init__(self, identifier='', label='', enums=[]): super().__init__() layout = QHBoxLayout(self) layout.setContentsMargins(10, 0, 0, 0) layout.setSpacing(0) + self.identifier = identifier self.label = QLabel(label) self.label.setFixedWidth(140) self.label.setContentsMargins(0, 0, 10, 0) @@ -233,72 +176,76 @@ class ComboBox(QWidget): def activated_connect(self, func): """Connect a function to the Enter/Return key press.""" self.value.activated.connect( - partial(func, self.value, lambda: self.value.currentText()) + partial(func, identifier=self.identifier, value_obj=self.value, value=lambda: self.value.currentText()) ) def setDisabled(self, disable): self.value.setDisabled(disable) -# class LED(QWidget): -# def __init__(self, states, colors, label): -# super().__init__() -# self.states = states -# self.colors = colors -# layout = QHBoxLayout(self) -# layout.setContentsMargins(10, 0, 0, 0) -# layout.setSpacing(0) -# self.label = QLabel(label) -# self.label.setFixedWidth(150) -# layout.addWidget(self.label) -# self.led = QLabel() -# self.led.setFixedWidth(160) -# layout.addWidget(self.led) +class Mover(QWidget): + def __init__(self, dev, egu, prec): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 0, 0, 0) + layout.setSpacing(0) + self.position = QLabel('-') + self.position.setFixedWidth(150) + layout.addWidget(self.position) + self.led = QLabel() + self.led.setFixedWidth(30) + self.led.setStyleSheet("background-color: 0, 0, 0; border: 1px solid black;") + layout.addWidget(self.led) + self.start = QPushButton('Move') + self.start.setStyleSheet("color: black; background-color: green;") + self.start.setFixedWidth(80) + self.stop = QPushButton('Stop') + self.stop.setStyleSheet("color: black; background-color: firebrick;") + self.stop.setFixedWidth(80) + layout.addWidget(self.start) + layout.addWidget(self.stop) + self.dev = dev + self.unit = egu + self.decimals = prec -# def apply_color(self, val): -# color = self.colors[self.states.index(val)] -# self.led.setStyleSheet(f"background-color: {color}; border: 1px solid black;") + def led_set_status(self, status): + if status in 'out': + self.led.setStyleSheet("background-color: 255, 0, 0; border: 1px solid black;") + elif status in 'moving': + self.led.setStyleSheet("background-color: 255, 255, 0; border: 1px solid black;") + elif status in 'in': + self.led.setStyleSheet("background-color: 0, 255, 0; border: 1px solid black;") -# class StartStop(QWidget): -# def __init__(self, label, label_buttons=['Start', 'Stop']): -# super().__init__() -# layout = QHBoxLayout(self) -# layout.setContentsMargins(10, 0, 0, 0) -# layout.setSpacing(0) -# self.label = QLabel(label) -# self.label.setFixedWidth(150) -# layout.addWidget(self.label) -# self.start = QPushButton(label_buttons[0]) -# self.start.setStyleSheet("color: black; background-color: green;") -# self.start.setFixedWidth(80) -# self.stop = QPushButton(label_buttons[1]) -# self.stop.setStyleSheet("color: black; background-color: firebrick;") -# self.stop.setFixedWidth(80) -# layout.addWidget(self.start) -# layout.addWidget(self.stop) + def position_setValue(self, number): + text = f'{number:.{int(self.decimals)}f}' + if self.unit is not None: + text = text + ' ' + self.unit + self.position.setText(text) -# def set_on_start(self, func): -# """Connect a function to the start button press.""" -# self.start.clicked.connect(func) + def start_clicked_connect(self, func): + """Connect a function to the start button press.""" + self.start.clicked.connect( + partial(func, dev=self.dev) + ) -# def set_on_stop(self, func): -# """Connect a function to the stop button press.""" -# self.stop.clicked.connect(func) + def stop_clicked_connect(self, func): + """Connect a function to the stop button press.""" + self.stop.clicked.connect( + partial(func, dev=self.dev) + ) -# def enable_start(self): -# self.start.setEnabled(True) -# self.start.setStyleSheet("color: black; background-color: green;") + def start_setEnabled(self, enable): + self.start.setEnabled(enable) + if enable: + self.start.setStyleSheet("color: black; background-color: green;") + else: + self.start.setStyleSheet("color: black; background-color: grey;") -# def enable_stop(self): -# self.stop.setEnabled(True) -# self.stop.setStyleSheet("color: black; background-color: firebrick;") - -# def disable_start(self): -# self.start.setEnabled(False) -# self.start.setStyleSheet("color: black; background-color: grey;") - -# def disable_stop(self): -# self.stop.setEnabled(False) -# self.stop.setStyleSheet("color: black; background-color: grey;") + def stop_setEnabled(self, enable): + self.stop.setEnabled(enable) + if enable: + self.stop.setStyleSheet("color: black; background-color: firebrick;") + else: + self.stop.setStyleSheet("color: black; background-color: grey;") # class Button(QWidget): # def __init__(self, label, label_button): @@ -328,3 +275,22 @@ class ComboBox(QWidget): # def set_button_text(self, text): # self.button.setText(text) + +# class LED(QWidget): +# def __init__(self, states, colors, label): +# super().__init__() +# self.states = states +# self.colors = colors +# layout = QHBoxLayout(self) +# layout.setContentsMargins(10, 0, 0, 0) +# layout.setSpacing(0) +# self.label = QLabel(label) +# self.label.setFixedWidth(150) +# layout.addWidget(self.label) +# self.led = QLabel() +# self.led.setFixedWidth(160) +# layout.addWidget(self.led) + +# def apply_color(self, val): +# color = self.colors[self.states.index(val)] +# self.led.setStyleSheet(f"background-color: {color}; border: 1px solid black;") \ No newline at end of file diff --git a/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py b/debye_bec/bec_widgets/widgets/x01da_parameters.py similarity index 100% rename from debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py rename to debye_bec/bec_widgets/widgets/x01da_parameters.py From acc5e320cf40eeaa2445ac52b9bf476949515f6c Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 6 May 2026 12:58:45 +0200 Subject: [PATCH 40/54] wip: digital twin --- .../widgets/digital_twin/calc_surfaces.py | 5 + .../widgets/digital_twin/digital_twin.py | 185 +++++++++++++----- 2 files changed, 139 insertions(+), 51 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py b/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py index f5d408a..bdaf703 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py @@ -24,6 +24,9 @@ def calc_surfaces(cfg): index = bl.cm.surface.index(cfg['cm_stripe']) cen = (bl.cm.limOptX[0][index] + bl.cm.limOptX[1][index]) / 2 + if cfg['cm_trx'] is not None: + cen = cfg['cm_trx'] + out['cm']['x'] = [cen-w1/2, cen-w2/2, cen+w2/2, cen+w1/2] out['cm']['y'] = [-l/2, l/2, l/2, -l/2] @@ -70,6 +73,8 @@ def calc_surfaces(cfg): index = surface.index(stripe) off = (bl.fm.limOptXFlat[0][index] + bl.fm.limOptXFlat[1][index]) / 2 r = bl.fm.r[index] + if cfg['fm_trx'] is not None: + off = cfg['fm_trx'] widthBeam = 2 * bl.fm.center[1] * np.tan(cfg['h_acc']) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index b3a9846..3146a34 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -148,9 +148,8 @@ class DigitalTwin(BECWidget, QWidget): def calc_reality(self): config = self.get_reality_config() beam = calc_sideview(config) - self.sideview_plot.data['reality']['x'] = beam['Z'] - self.sideview_plot.data['reality']['y'] = beam['Y'] - self.sideview_plot.update_curves() + data = {'x': beam['Z'], 'y': beam['Y']} + self.sideview_plot.update_curves('reality', data) surfaces = calc_surfaces(config) self.surface_plots.update_surfaces(scene='reality', data=surfaces) @@ -233,11 +232,13 @@ class DigitalTwin(BECWidget, QWidget): 'v_acc' : self.input.sldi_vacc.value() * 1e-3, 'cm_pitch' : -self.input.cm_pitch.value() * 1e-3, 'cm_stripe' : self.input.cm_stripe.currentText(), + 'cm_trx' : None, 'mo1_mode' : self.input.mo1_mode.currentText(), 'mo1_xtal' : self.input.mo1_xtal.currentText(), 'mo1_bragg' : self.bragg_angle, 'fm_pitch' : -self.input.fm_pitch.value() * 1e-3, 'fm_stripe' : self.input.fm_stripe.currentText(), + 'fm_trx' : None, 'fm_gain_height' : 1, 'smpl' : self.input.smpl.value(), } @@ -277,11 +278,13 @@ class DigitalTwin(BECWidget, QWidget): 'v_acc' : v_acc, 'cm_pitch' : cm_pitch, 'cm_stripe' : cm_stripe, + 'cm_trx' : cm_trx, 'mo1_mode' : mo1_mode, 'mo1_xtal' : mo1_bragg['mo1_bragg_crystal_current_xtal_string']['value'], 'mo1_bragg' : mo1_bragg['mo1_bragg_angle']['value']/180*np.pi, 'fm_pitch' : fm_pitch_real, 'fm_stripe' : fm_stripe, + 'fm_trx' : fm_trx, 'fm_gain_height' : 1, 'smpl' : self.dev.ot_es1_trz.read()['ot_es1_trz']['value'], } @@ -291,9 +294,8 @@ class DigitalTwin(BECWidget, QWidget): @SafeSlot() def calc_assistant_sideview(self): beam = calc_sideview(self.get_assistant_config()) - self.sideview_plot.data['assistant']['x'] = beam['Z'] - self.sideview_plot.data['assistant']['y'] = beam['Y'] - self.sideview_plot.update_curves() + data = {'x': beam['Z'], 'y': beam['Y']} + self.sideview_plot.update_curves('assistant', data) @SafeSlot() def calc_assistant_surfaces(self): @@ -453,8 +455,8 @@ class InputPanel(QWidget): # Focusing Mirror self.fm_stripe = ComboBox('fm_stripe', 'Stripe', ['Rh (toroid)', 'Rh (flat)', 'Pt (toroid)', 'Pt (flat)']) - self.fm_pitch_ideal = NumberIndicator('Ideal Pitch', 'mrad', decimals=3) - self.fm_pitch = InputNumberField('fm_pitch', 'Pitch [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-10, hl=2) + self.fm_pitch_ideal = NumberIndicator('Ideal Incidence Angle', 'mrad', decimals=3) + self.fm_pitch = InputNumberField('fm_pitch', 'Incidence Angle [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-10, hl=2) self.fm_refl = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.fm_refl_harm = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.fm_ass_group = Group( @@ -656,11 +658,11 @@ class MoverPanel(QWidget): self._layout .addWidget(self.mover_group) self._layout .addStretch() -class SurfacePlots(QWidget): +class SurfacePlots(BECWidget, QWidget): """Plot widget with two curves and legend.""" - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self, parent=None, *arg, **kwargs): + super().__init__(parent=parent, theme_update=True, *arg, **kwargs) self._layout = QHBoxLayout(self) self.surfaces = { @@ -685,16 +687,9 @@ class SurfacePlots(QWidget): 'fm': {}, } - app = QApplication.instance() - theme = app.theme.theme # type: ignore - if theme == "light": - self.color_impenetrable = (30, 30, 30) - self.colors = [(79, 163, 224), (240, 128, 60)] - self.text_color = (255, 255, 255) - else: # dark theme - self.color_impenetrable = (220, 220, 220) - self.colors = [(26, 111, 173), (212, 83, 10)] - self.text_color = (0, 0, 0) + self.color_impenetrable = (0, 0, 0) + self.colors = [(255, 255, 0), (255, 0, 255)] + self.text_color = (255, 255, 255) # Create plot widgets for name, widget in self.plots.items(): @@ -739,8 +734,57 @@ class SurfacePlots(QWidget): ) self.plots[name][scene].setZValue(z_value) + self.walls = [] + self.texts = [] + self.plot_walls() + self.apply_theme() + + def apply_theme(self, theme=None): + + if theme is None: + app = QApplication.instance() + theme = app.theme.theme # type: ignore + + bg_color = pg.getConfigOption("background") + fg_color = pg.getConfigOption("foreground") + for _, plot in self.plots.items(): + # Background + plot['widget'].setBackground(bg_color) + # Axes (tick marks, tick labels, axis line) + for axis in ["left", "bottom", "right", "top"]: + ax = plot['widget'].getAxis(axis) + ax.setPen(pg.mkPen(color=fg_color)) + ax.setTextPen(pg.mkPen(color=fg_color)) + + if theme == "light": + self.color_impenetrable = (30, 30, 30) + self.colors = [(79, 163, 224), (240, 128, 60)] + self.text_color = (255, 255, 255) + else: # dark theme + self.color_impenetrable = (220, 220, 220) + self.colors = [(26, 111, 173), (212, 83, 10)] + self.text_color = (0, 0, 0) + + for idx, scene in enumerate(self.surfaces): + for name, device in self.surfaces[scene].items(): + if scene in 'assistant': + brush = QBrush(QColor(*self.colors[idx], 255), Qt.DiagCrossPattern) + pen = pg.mkPen(QColor(*self.colors[idx], 255), width=1, style=Qt.DashLine) + else: + brush = QBrush(QColor(*self.colors[idx], 255)) + pen = pg.mkPen(QColor(*self.colors[idx], 255), width=1) + self.plots[name][scene].setPen(pen) + self.plots[name][scene].setBrush(brush) + + for wall in self.walls: + wall.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) + wall.setBrush(pg.QtGui.QBrush(pg.QtGui.QColor(*self.color_impenetrable))) # pylint: disable=E1101 + + for text in self.texts: + text.setColor(self.text_color) + def plot_walls(self): def plot_mirror_stripe(widget, surface, limOptX, limOptY): @@ -758,6 +802,8 @@ class SurfacePlots(QWidget): widget.addItem(text) text.setPos((hx+lx)/2, (hy+ly)/2) text.setZValue(10) + self.walls.append(rect) + self.texts.append(text) def plot_mono_surface(widget, xtal, xtalWidth, xtalOffsetX, xtalLength): for sf, w, offx, len in zip(xtal, xtalWidth, xtalOffsetX, xtalLength): @@ -774,6 +820,8 @@ class SurfacePlots(QWidget): widget.addItem(text) text.setPos(offx, 0) text.setZValue(10) + self.walls.append(rect) + self.texts.append(text) for name, plot in self.plots.items(): if name in 'cm': @@ -798,11 +846,11 @@ class SurfacePlots(QWidget): y = np.array(device['y'] + [device['y'][0]]) if len(device['y']) != 0 else np.array([]) plot.setData(x=x, y=y) -class SideviewPlot(QWidget): +class SideviewPlot(BECWidget, QWidget): """Plot widget with two curves and legend.""" - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self, parent=None, *arg, **kwargs): + super().__init__(parent=parent, theme_update=True, *arg, **kwargs) self._layout = QVBoxLayout(self) # self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore @@ -810,36 +858,29 @@ class SideviewPlot(QWidget): self.plot_widget.getAxis('bottom').enableAutoSIPrefix(False) self.plot_widget.addLegend() - app = QApplication.instance() - theme = app.theme.theme # type: ignore - if theme == "light": - self.color_impenetrable = (30, 30, 30) - self.colors = [(26, 111, 173), (212, 83, 10)] - else: # dark theme - self.color_impenetrable = (220, 220, 220) - self.colors = [(79, 163, 224), (240, 128, 60)] - - self.curves = [] + self.color_impenetrable = (0, 0, 0) + self.colors = [(255, 255, 0), (255, 0, 255)] self.data = { 'assistant': {'x': [0, 1000, 2000], 'y': [0, 20, 30]}, 'reality': {'x': [0, 1000, 2000], 'y': [0, 15, 50]}, } + + self.plots = {} + self.pipes = [] self.walls = [] - for idx, name in enumerate(self.data.keys()): - if name in "assistant": + for idx, scene in enumerate(self.data.keys()): + if scene in "assistant": pen = pg.mkPen(color=self.colors[idx], width=2, style=Qt.DashLine) else: pen = pg.mkPen(color=self.colors[idx], width=2) - self.curves.append( - self.plot_widget.plot( - [], - [], - pen=pen, - name=name, - ) + self.plots[scene] = self.plot_widget.plot( + [], + [], + pen=pen, + name=scene, ) self.plot_group = Group( @@ -862,7 +903,50 @@ class SideviewPlot(QWidget): self.plot_vacuum_pipes() self.plot_walls() - self.update_curves() + + self.apply_theme() + + def apply_theme(self, theme=None): + + if theme is None: + app = QApplication.instance() + theme = app.theme.theme # type: ignore + + bg_color = pg.getConfigOption("background") + fg_color = pg.getConfigOption("foreground") + # Background + self.plot_widget.setBackground(bg_color) + # Axes (tick marks, tick labels, axis line) + for axis in ["left", "bottom", "right", "top"]: + ax = self.plot_widget.getAxis(axis) + ax.setPen(pg.mkPen(color=fg_color)) + ax.setTextPen(pg.mkPen(color=fg_color)) + + if theme == "light": + self.color_impenetrable = (30, 30, 30) + self.colors = [(79, 163, 224), (240, 128, 60)] + self.text_color = (255, 255, 255) + else: # dark theme + self.color_impenetrable = (220, 220, 220) + self.colors = [(26, 111, 173), (212, 83, 10)] + self.text_color = (0, 0, 0) + + for idx, scene in enumerate(self.data): + if scene in 'assistant': + brush = QBrush(QColor(*self.colors[idx], 255), Qt.DiagCrossPattern) + pen = pg.mkPen(QColor(*self.colors[idx], 255), width=1, style=Qt.DashLine) + else: + brush = QBrush(QColor(*self.colors[idx], 255)) + pen = pg.mkPen(QColor(*self.colors[idx], 255), width=1) + self.plots[scene].setPen(pen) + self.plots[scene].setBrush(brush) + + for wall in self.walls: + wall.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) + wall.setBrush(pg.QtGui.QBrush(pg.QtGui.QColor(*self.color_impenetrable))) # pylint: disable=E1101 + + for pipe in self.pipes: + pipe.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) def plot_vacuum_pipes(self): for i, _ in enumerate(bl.vacuum_pipes.center): @@ -890,13 +974,12 @@ class SideviewPlot(QWidget): rect.setBrush(pg.QtGui.QBrush(pg.QtGui.QColor(*self.color_impenetrable))) # pylint: disable=E1101 rect.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) self.plot_widget.addItem(rect) + self.walls.append(rect) - def update_curves(self): - for idx, element in enumerate(self.data): - self.curves[idx].setData( - x=np.array(self.data[element]['x']), - y=np.array(self.data[element]['y']), - ) + def update_curves(self, scene, data): + self.data[scene] = data + plot = self.plots[scene] + plot.setData(x=self.data[scene]['x'], y=self.data[scene]['y']) if __name__ == "__main__": @@ -911,4 +994,4 @@ if __name__ == "__main__": # win.resize(1000, 800) win.show() - sys.exit(app.exec_()) \ No newline at end of file + sys.exit(app.exec_()) From b0a7d6905cd2edef01d25770cdf8666af12762f1 Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 6 May 2026 14:53:06 +0200 Subject: [PATCH 41/54] wip: digital twin --- .../digital_twin/calculate_positions.py | 5 +- .../widgets/digital_twin/digital_twin.py | 78 +++++++++++++++++-- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py b/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py index 8012baf..971b7e1 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py @@ -146,7 +146,10 @@ def calc_positions(cfg): f = (p*q)/(p+q) # focal length # Bender radius - radius = 2 * q / np.sin(cfg['fm_pitch']) # ideal bending radius + if cfg['fm_qy'] is None: + radius = 2 * q / np.sin(cfg['fm_pitch']) # ideal bending radius for focused beam + else: + radius = 2 * cfg['fm_qy'] / np.sin(cfg['fm_pitch']) # ideal bending radius for unfocused beam pos['fm_bnd_radius'] = {'value': radius * 1e-6} # Convert to km # Pitch diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 3146a34..0c739d1 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -88,10 +88,14 @@ class DigitalTwin(BECWidget, QWidget): self.input.mo1_mode.activated_connect(self.calc_assistant) self.input.mo1_xtal.activated_connect(self.calc_assistant) self.input.fm_stripe.activated_connect(self.calc_assistant) + self.input.fm_focus.activated_connect(self.calc_assistant) self.input.fm_pitch.value_changed_connect(self.calc_assistant) + self.input.fm_focx.value_changed_connect(self.calc_assistant) + self.input.fm_focy.value_changed_connect(self.calc_assistant) self.input.smpl.value_changed_connect(self.calc_assistant) self.bragg_angle = 0 + self.qy = 0 # Initialize all values self.calc_assistant(identifier='init') @@ -111,6 +115,7 @@ class DigitalTwin(BECWidget, QWidget): self.calc_mo1_bragg_angle() self.calc_cm_crit_pitch() self.calc_cm_reflectivity() + self.update_fm_mode() self.calc_fm_reflectivity() self.calc_cm_fm_harm_suppr() self.calc_fm_ideal_pitch() @@ -134,6 +139,13 @@ class DigitalTwin(BECWidget, QWidget): case 'mo1_xtal': self.calc_mo1_bragg_angle() self.calc_mo1_energy_resolution() + case 'fm_focus': + self.update_fm_mode() + self.calc_fm_ideal_pitch() + case 'fm_focx': + self.calc_fm_ideal_pitch() + case 'fm_focy': + self.calc_fm_ideal_pitch() case 'fm_stripe': self.calc_fm_reflectivity() self.calc_cm_fm_harm_suppr() @@ -144,6 +156,24 @@ class DigitalTwin(BECWidget, QWidget): self.calc_assistant_sideview() self.calc_assistant_surfaces() + def update_fm_mode(self): + fm_focus = self.input.fm_focus.currentText() + if fm_focus in 'Manual': + self.input.fm_pitch.setVisible(True) + self.input.fm_pitch_ideal.setVisible(True) + self.input.fm_focx.setVisible(False) + self.input.fm_focy.setVisible(False) + elif fm_focus in 'Focused': + self.input.fm_pitch.setVisible(False) + self.input.fm_pitch_ideal.setVisible(True) + self.input.fm_focx.setVisible(False) + self.input.fm_focy.setVisible(False) + else: # Defocused + self.input.fm_pitch.setVisible(False) + self.input.fm_pitch_ideal.setVisible(True) + self.input.fm_focx.setVisible(True) + self.input.fm_focy.setVisible(True) + @SafeSlot() def calc_reality(self): config = self.get_reality_config() @@ -226,6 +256,18 @@ class DigitalTwin(BECWidget, QWidget): self.input.cm_fm_harm_suppr.setLabel(f"Total Suppression Factor at {3 * self.input.energy.value():.0f} eV") def get_assistant_config(self): + + fm_focus = self.input.fm_focus.currentText() + if fm_focus in 'Manual': + fm_pitch = self.input.fm_pitch.value() + fm_qy = None + elif fm_focus in 'Focused': + fm_pitch = self.input.fm_pitch_ideal.value() + fm_qy = None + else: # Focused + fm_pitch = self.input.fm_pitch_ideal.value() + fm_qy = self.qy + config = { # Config in SI units! 'energy' : self.input.energy.value(), 'h_acc' : self.input.sldi_hacc.value() * 1e-3, @@ -236,9 +278,10 @@ class DigitalTwin(BECWidget, QWidget): 'mo1_mode' : self.input.mo1_mode.currentText(), 'mo1_xtal' : self.input.mo1_xtal.currentText(), 'mo1_bragg' : self.bragg_angle, - 'fm_pitch' : -self.input.fm_pitch.value() * 1e-3, + 'fm_pitch' : -fm_pitch * 1e-3, 'fm_stripe' : self.input.fm_stripe.currentText(), 'fm_trx' : None, + 'fm_qy' : fm_qy, 'fm_gain_height' : 1, 'smpl' : self.input.smpl.value(), } @@ -364,15 +407,28 @@ class DigitalTwin(BECWidget, QWidget): @SafeSlot() def update_mo1_mode(self): if self.input.mo1_mode.currentText() in 'Monochromatic': - self.input.mo1_xtal.setDisabled(False) + self.input.mo1_xtal.setVisible(True) + self.input.mo1_bragg_angle.setVisible(True) + self.input.mo1_eres.setVisible(True) else: - self.input.mo1_xtal.setDisabled(True) + self.input.mo1_xtal.setVisible(False) + self.input.mo1_bragg_angle.setVisible(False) + self.input.mo1_eres.setVisible(False) @SafeSlot() def calc_fm_ideal_pitch(self): p = bl.fm.center[1] # posFM q = self.input.smpl.value() - bl.fm.center[1] # dist posFM to posEX - f = (p * q) / (p + q) # focal length + if self.input.fm_focus.currentText() in 'Defocused': + a = 2 * np.tan(self.input.sldi_hacc.value() * 1e-3) * bl.fm.center[1] # Beam width at focusing mirror + b = 2 * np.tan(self.input.sldi_vacc.value() * 1e-3) * bl.cm.center[1] # Beam height at focusing mirror (collimated beam) + x = self.input.fm_focx.value() + y = self.input.fm_focy.value() + qx = q + x * p / a + self.qy = q + y * p / b + f = (p * qx) / (p + qx) # focal length + else: # Calculate for focused beam on sample in "manual" and "focused" mode + f = (p * q) / (p + q) # focal length pitch = 0 if 'Rh' in self.input.fm_stripe.currentText(): pitch = np.arcsin(bl.fm.r[0]/(2*f))# ideal pitch for FM @@ -423,16 +479,16 @@ class InputPanel(QWidget): # Collimating mirror self.cm_stripe = ComboBox('cm_stripe', 'Stripe', ['Si', 'Rh', 'Pt']) - self.cm_pitch_critical = NumberIndicator('Critical Pitch', 'mrad', decimals=3) self.cm_pitch = InputNumberField('cm_pitch', 'Pitch [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-4.6, hl=-1.2) + self.cm_pitch_critical = NumberIndicator('Critical Pitch', 'mrad', decimals=3) self.cm_refl = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.cm_refl_harm = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.cm_ass_group = Group( 'Collimating Mirror', [ self.cm_stripe, - self.cm_pitch_critical, self.cm_pitch, + self.cm_pitch_critical, self.cm_refl, self.cm_refl_harm, ] @@ -455,16 +511,22 @@ class InputPanel(QWidget): # Focusing Mirror self.fm_stripe = ComboBox('fm_stripe', 'Stripe', ['Rh (toroid)', 'Rh (flat)', 'Pt (toroid)', 'Pt (flat)']) - self.fm_pitch_ideal = NumberIndicator('Ideal Incidence Angle', 'mrad', decimals=3) + self.fm_focus = ComboBox('fm_focus', 'Focus Type', ['Manual', 'Focused', 'Defocused']) self.fm_pitch = InputNumberField('fm_pitch', 'Incidence Angle [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-10, hl=2) + self.fm_focx = InputNumberField('fm_focx', 'Beam Size Horizontal [mm]', init=1, decimals=1, single_step=0.1, ll=0, hl=30) + self.fm_focy = InputNumberField('fm_focy', 'Beam Size Vertical [mm]', init=1, decimals=1, single_step=0.1, ll=0, hl=10) + self.fm_pitch_ideal = NumberIndicator('Incidence Angle for focused beam', 'mrad', decimals=3) self.fm_refl = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.fm_refl_harm = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.fm_ass_group = Group( 'Focusing Mirror', [ self.fm_stripe, - self.fm_pitch_ideal, + self.fm_focus, self.fm_pitch, + self.fm_focx, + self.fm_focy, + self.fm_pitch_ideal, self.fm_refl, self.fm_refl_harm, ] From 6da7e665b342e8fcd7d419af8c44c45c59739683 Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 6 May 2026 16:12:25 +0200 Subject: [PATCH 42/54] wip: digital twin --- ...lculate_positions.py => calc_positions.py} | 0 ...calculate_sideview.py => calc_sideview.py} | 0 .../widgets/digital_twin/calc_varia.py | 173 ++++++++++ .../widgets/digital_twin/digital_twin.py | 307 +++++++----------- .../bec_widgets/widgets/x01da_parameters.py | 2 +- 5 files changed, 285 insertions(+), 197 deletions(-) rename debye_bec/bec_widgets/widgets/digital_twin/{calculate_positions.py => calc_positions.py} (100%) rename debye_bec/bec_widgets/widgets/digital_twin/{calculate_sideview.py => calc_sideview.py} (100%) create mode 100644 debye_bec/bec_widgets/widgets/digital_twin/calc_varia.py diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py b/debye_bec/bec_widgets/widgets/digital_twin/calc_positions.py similarity index 100% rename from debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py rename to debye_bec/bec_widgets/widgets/digital_twin/calc_positions.py diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calculate_sideview.py b/debye_bec/bec_widgets/widgets/digital_twin/calc_sideview.py similarity index 100% rename from debye_bec/bec_widgets/widgets/digital_twin/calculate_sideview.py rename to debye_bec/bec_widgets/widgets/digital_twin/calc_sideview.py diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calc_varia.py b/debye_bec/bec_widgets/widgets/digital_twin/calc_varia.py new file mode 100644 index 0000000..55bd5e1 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/digital_twin/calc_varia.py @@ -0,0 +1,173 @@ +import sys +import re +import numpy as np +from scipy.interpolate import UnivariateSpline +from xrt.backends.raycing.physconsts import CHeVcm, AVOGADRO +from bec_lib import bec_logger + +# pylint: disable=E0611 +from qtpy.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QApplication, + QLayout, +) +# pylint: disable=E0611 +from qtpy.QtCore import ( + Qt, + QTimer, +) +from qtpy.QtGui import ( + QColor, + QBrush, +) +import pyqtgraph as pg + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot + +from debye_bec.bec_widgets.widgets.qt_widgets import ( + InputNumberField, + ComboBox, + Group, + NumberIndicator, + Mover, +) +from debye_bec.bec_widgets.widgets.digital_twin.calc_positions import calc_positions +from debye_bec.bec_widgets.widgets.digital_twin.calc_sideview import calc_sideview +from debye_bec.bec_widgets.widgets.digital_twin.calc_surfaces import calc_surfaces + +import debye_bec.bec_widgets.widgets.x01da_parameters as bl + +logger = bec_logger.logger + +def sldi_gap_to_acc(sldi_gapx, sldi_gapy): + d1 = bl.feSlits.center1[1] + h_acc = np.tan(sldi_gapx / (2 * d1)) + v_acc = np.tan(sldi_gapy / (2 * d1)) + return h_acc, v_acc + +def cm_trx_to_stripe(cm_trx): + cm_stripe = None + for name, low, high in zip(bl.cm.surface, bl.cm.limOptX[0], bl.cm.limOptX[1]): + if low <= cm_trx <= high: + cm_stripe = name + return cm_stripe + +def fm_trx_to_stripe(fm_trx): + fm_stripe = None + for name, low, high in zip(bl.fm.surfaceFlat, bl.fm.limOptXFlat[1], bl.fm.limOptXFlat[0]): + if low <= fm_trx <= high: + fm_stripe = name + ' (flat)' + for name, low, high in zip(bl.fm.surfaceToroid, bl.fm.limOptXToroid[1], bl.fm.limOptXToroid[0]): + if low <= fm_trx <= high: + fm_stripe = name + ' (toroid)' + return fm_stripe + +def mo1_energy_resolution(xtal, energy): + index = bl.mo1.xtal.index(xtal) + crystal = bl.mo1.material1[index] + + dtheta = np.linspace(-30, 90, 601) + theta = crystal.get_Bragg_angle(energy) + dtheta * 1e-6 + refl = np.abs(crystal.get_amplitude(energy, np.sin(theta))[0])**2 # single crystal + + refl2 = refl**2 # DCM with parallel crystals + + # FWHM of the DCM curve + spline = UnivariateSpline(dtheta, refl2 - refl2.max()/2, s=0) + r1, r2 = spline.roots() + fwhm_rad = (r2 - r1) * 1e-6 # µrad → rad + + # Energy resolution + theta_B = crystal.get_Bragg_angle(energy) + dE_over_E = fwhm_rad / np.tan(theta_B) + dE = dE_over_E * energy + + # logger.info(f"DCM FWHM : {r2-r1:.2f} µrad") + # logger.info(f"ΔE/E : {dE_over_E:.2e}") + # logger.info(f"ΔE : {dE:.3f} eV at {E} eV") + + return dE + +def cm_reflectivity(cm_stripe, cm_pitch, energy): + index = bl.cm.surface.index(cm_stripe) + rs, rp = bl.cm.material[index].get_amplitude( + energy, + np.sin(cm_pitch) + )[0:2] + refl = abs(rs)**2 + return refl + +def fm_reflectivity(fm_stripe, fm_pitch, energy): + if fm_stripe in ('Rh (toroid)', 'Pt (toroid)'): + surface = bl.fm.surfaceToroid + material = bl.fm.materialToroid + stripe = re.sub(r'\s*\(.*?\)', '', fm_stripe).strip() + index = surface.index(stripe) + else: + surface = bl.fm.surfaceFlat + material = bl.fm.materialFlat + stripe = re.sub(r'\s*\(.*?\)', '', fm_stripe).strip() + index = surface.index(stripe) + rs, rp = material[index].get_amplitude( + energy, + np.sin(fm_pitch) + )[0:2] + refl = abs(rs)**2 + return refl + +def mo1_bragg_angle(mo_mode, d_spacing, energy, cm_pitch): + H = 6.62606957E-34 + E = 1.602176634E-19 + C = 299792458 + wl = C * H / (E * energy) + val = wl / (2 * d_spacing * 1e-10) + bragg_angle = 0 + if val > -1 and val < 1: + bragg_angle = np.asin(val) + if mo_mode in 'Monochromatic': + # Add 2x CM pitch to the bragg angle + bragg_angle_cor = ((2 * cm_pitch) + bragg_angle) + elif mo_mode in 'Pinkbeam': + # Align xtal surfaces parallel to beam + bragg_angle_cor = (2 * cm_pitch) + return bragg_angle, bragg_angle_cor + +def fm_ideal_pitch(fm_focus, fm_stripe, smpl, sldi_hacc=None, sldi_vacc=None, fm_focx=None, fm_focy=None): + p = bl.fm.center[1] # posFM + q = smpl - bl.fm.center[1] # dist posFM to posEX + if fm_focus in 'Defocused': + a = 2 * np.tan(sldi_hacc) * bl.fm.center[1] # Beam width at focusing mirror + b = 2 * np.tan(sldi_vacc) * bl.cm.center[1] # Beam height at focusing mirror (collimated beam) + x = fm_focx + y = fm_focy + qx = q + x * p / a + qy = q + y * p / b + f = (p * qx) / (p + qx) # focal length + else: # Calculate for focused beam on sample in "manual" and "focused" mode + qy = None + f = (p * q) / (p + q) # focal length + pitch = 0 + if 'Rh' in fm_stripe: + pitch = np.arcsin(bl.fm.r[0]/(2*f))# ideal pitch for FM + if 'Pt' in fm_stripe: + pitch = np.arcsin(bl.fm.r[1]/(2*f)) # ideal pitch for FM + return pitch, qy + +def cm_critical_angle(cm_stripe, energy): + if cm_stripe in 'Si': + stripe = bl.stripeSi + elif cm_stripe in 'Pt': + stripe = bl.stripePt + elif cm_stripe in 'Rh': + stripe = bl.stripeRh + else: + raise Exception(f'Stripe {stripe} not found in beamline parameters!') + w = CHeVcm/100/energy # convert energy [eV] to wavelength [m] + # Calculate critical angle for mirror + f1 = stripe.elements[0].Z + np.real(stripe.elements[0].get_f1f2(energy)) + numberDensity = stripe.rho*1e3*AVOGADRO/(stripe.elements[0].mass/1e3) + criticalAngle = np.sqrt(numberDensity*2.8179e-15*w**2*f1/np.pi) + return criticalAngle diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 0c739d1..cabaa96 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -38,9 +38,20 @@ from debye_bec.bec_widgets.widgets.qt_widgets import ( NumberIndicator, Mover, ) -from debye_bec.bec_widgets.widgets.digital_twin.calculate_positions import calc_positions -from debye_bec.bec_widgets.widgets.digital_twin.calculate_sideview import calc_sideview +from debye_bec.bec_widgets.widgets.digital_twin.calc_positions import calc_positions +from debye_bec.bec_widgets.widgets.digital_twin.calc_sideview import calc_sideview from debye_bec.bec_widgets.widgets.digital_twin.calc_surfaces import calc_surfaces +from debye_bec.bec_widgets.widgets.digital_twin.calc_varia import ( + sldi_gap_to_acc, + cm_trx_to_stripe, + fm_trx_to_stripe, + mo1_energy_resolution, + cm_reflectivity, + fm_reflectivity, + mo1_bragg_angle, + fm_ideal_pitch, + cm_critical_angle, +) import debye_bec.bec_widgets.widgets.x01da_parameters as bl @@ -104,9 +115,12 @@ class DigitalTwin(BECWidget, QWidget): self._timer = QTimer(self) self._timer.setInterval(1000) self._timer.timeout.connect(self.calc_reality) - # TODO: Check if I need to stop the timer if the widget is closed? self._timer.start() + def apply_theme(self, theme): + self.sideview_plot.apply_theme(theme) + self.surface_plots.apply_theme(theme) + @SafeSlot() def calc_assistant(self, *args, **kwargs): identifier = kwargs['identifier'] @@ -156,105 +170,6 @@ class DigitalTwin(BECWidget, QWidget): self.calc_assistant_sideview() self.calc_assistant_surfaces() - def update_fm_mode(self): - fm_focus = self.input.fm_focus.currentText() - if fm_focus in 'Manual': - self.input.fm_pitch.setVisible(True) - self.input.fm_pitch_ideal.setVisible(True) - self.input.fm_focx.setVisible(False) - self.input.fm_focy.setVisible(False) - elif fm_focus in 'Focused': - self.input.fm_pitch.setVisible(False) - self.input.fm_pitch_ideal.setVisible(True) - self.input.fm_focx.setVisible(False) - self.input.fm_focy.setVisible(False) - else: # Defocused - self.input.fm_pitch.setVisible(False) - self.input.fm_pitch_ideal.setVisible(True) - self.input.fm_focx.setVisible(True) - self.input.fm_focy.setVisible(True) - - @SafeSlot() - def calc_reality(self): - config = self.get_reality_config() - beam = calc_sideview(config) - data = {'x': beam['Z'], 'y': beam['Y']} - self.sideview_plot.update_curves('reality', data) - surfaces = calc_surfaces(config) - self.surface_plots.update_surfaces(scene='reality', data=surfaces) - - def calc_mo1_energy_resolution(self, *args, **kwargs): - xtal = self.input.mo1_xtal.currentText().translate(str.maketrans('', '', '()')) # Remove brackets from xtal name to conform with parameters - index = bl.mo1.xtal.index(xtal) - crystal = bl.mo1.material1[index] - E = self.input.energy.value() - - dtheta = np.linspace(-30, 90, 601) - theta = crystal.get_Bragg_angle(E) + dtheta * 1e-6 - refl = np.abs(crystal.get_amplitude(E, np.sin(theta))[0])**2 # single crystal - - refl2 = refl**2 # DCM with parallel crystals - - # FWHM of the DCM curve - spline = UnivariateSpline(dtheta, refl2 - refl2.max()/2, s=0) - r1, r2 = spline.roots() - fwhm_rad = (r2 - r1) * 1e-6 # µrad → rad - - # Energy resolution - theta_B = crystal.get_Bragg_angle(E) - dE_over_E = fwhm_rad / np.tan(theta_B) - dE = dE_over_E * E - - # logger.info(f"DCM FWHM : {r2-r1:.2f} µrad") - # logger.info(f"ΔE/E : {dE_over_E:.2e}") - # logger.info(f"ΔE : {dE:.3f} eV at {E} eV") - - self.input.mo1_eres.setValue(dE) - - def calc_cm_reflectivity(self): - index = bl.cm.surface.index(self.input.cm_stripe.currentText()) - rs, rp = bl.cm.material[index].get_amplitude( - self.input.energy.value(), - np.sin(-self.input.cm_pitch.value() * 1e-3) - )[0:2] - self.input.cm_refl.setValue(100 * abs(rs)**2) - self.input.cm_refl.setLabel(f"Reflectivity at \n{self.input.energy.value():.0f} eV") - rs, rp = bl.cm.material[index].get_amplitude( - 2 * self.input.energy.value(), - np.sin(-self.input.cm_pitch.value() * 1e-3) - )[0:2] - self.input.cm_refl_harm.setValue(100 * abs(rs)**2) - self.input.cm_refl_harm.setLabel(f"Reflectivity at \n{3 * self.input.energy.value():.0f} eV") - - def calc_fm_reflectivity(self): - if self.input.fm_stripe.currentText() in ('Rh (toroid)', 'Pt (toroid)'): - surface = bl.fm.surfaceToroid - material = bl.fm.materialToroid - stripe = re.sub(r'\s*\(.*?\)', '', self.input.fm_stripe.currentText()).strip() - index = surface.index(stripe) - else: - surface = bl.fm.surfaceFlat - material = bl.fm.materialFlat - stripe = re.sub(r'\s*\(.*?\)', '', self.input.fm_stripe.currentText()).strip() - index = surface.index(stripe) - rs, rp = material[index].get_amplitude( - self.input.energy.value(), - np.sin(-self.input.fm_pitch.value() * 1e-3) - )[0:2] - self.input.fm_refl.setValue(100 * abs(rs)**2) - self.input.fm_refl.setLabel(f"Reflectivity at \n{self.input.energy.value():.0f} eV") - rs, rp = material[index].get_amplitude( - 2 * self.input.energy.value(), - np.sin(-self.input.fm_pitch.value() * 1e-3) - )[0:2] - self.input.fm_refl_harm.setValue(100 * abs(rs)**2) - self.input.fm_refl_harm.setLabel(f"Reflectivity at \n{3 * self.input.energy.value():.0f} eV") - - def calc_cm_fm_harm_suppr(self): - harm_suppr = (self.input.cm_refl.value() * self.input.fm_refl.value()) / (self.input.cm_refl_harm.value() * self.input.fm_refl_harm.value()) - self.input.cm_fm_harm_suppr.setValue(harm_suppr) - self.input.cm_fm_harm_suppr.setLabel(f"Total Suppression Factor at {3 * self.input.energy.value():.0f} eV") - def get_assistant_config(self): fm_focus = self.input.fm_focus.currentText() @@ -296,23 +211,12 @@ class DigitalTwin(BECWidget, QWidget): mo1_bragg = self.dev.mo1_bragg.read() sldi_gapx = self.dev.sldi_gapx.read()['sldi_gapx']['value'] sldi_gapy = self.dev.sldi_gapy.read()['sldi_gapy']['value'] - d1 = bl.feSlits.center1[1] - h_acc = np.tan(sldi_gapx / (2 * d1)) - v_acc = np.tan(sldi_gapy / (2 * d1)) + h_acc, v_acc = sldi_gap_to_acc(sldi_gapx, sldi_gapy) cm_trx = -self.dev.cm_trx.read()['cm_trx']['value'] - cm_stripe = None - for name, low, high in zip(bl.cm.surface, bl.cm.limOptX[0], bl.cm.limOptX[1]): - if low <= cm_trx <= high: - cm_stripe = name + cm_stripe = cm_trx_to_stripe(cm_trx) cm_pitch = -self.dev.cm_rotx.read()['cm_rotx']['value'] * 1e-3 fm_trx = -self.dev.fm_trx.read()['fm_trx']['value'] - fm_stripe = None - for name, low, high in zip(bl.fm.surfaceFlat, bl.fm.limOptXFlat[1], bl.fm.limOptXFlat[0]): - if low <= fm_trx <= high: - fm_stripe = name + ' (flat)' - for name, low, high in zip(bl.fm.surfaceToroid, bl.fm.limOptXToroid[1], bl.fm.limOptXToroid[0]): - if low <= fm_trx <= high: - fm_stripe = name + ' (toroid)' + fm_stripe = fm_trx_to_stripe(fm_trx) fm_pitch = -self.dev.fm_rotx.read()['fm_rotx']['value'] * 1e-3 fm_pitch_real = 2 * cm_pitch - fm_pitch config = { # Config in SI units! @@ -334,21 +238,75 @@ class DigitalTwin(BECWidget, QWidget): # logger.info(f'Config created: {config}') return config - @SafeSlot() + def update_fm_mode(self): + fm_focus = self.input.fm_focus.currentText() + if fm_focus in 'Manual': + self.input.fm_pitch.setVisible(True) + self.input.fm_pitch_ideal.setVisible(True) + self.input.fm_focx.setVisible(False) + self.input.fm_focy.setVisible(False) + elif fm_focus in 'Focused': + self.input.fm_pitch.setVisible(False) + self.input.fm_pitch_ideal.setVisible(True) + self.input.fm_focx.setVisible(False) + self.input.fm_focy.setVisible(False) + else: # Defocused + self.input.fm_pitch.setVisible(False) + self.input.fm_pitch_ideal.setVisible(True) + self.input.fm_focx.setVisible(True) + self.input.fm_focy.setVisible(True) + + def calc_reality(self): + config = self.get_reality_config() + beam = calc_sideview(config) + data = {'x': beam['Z'], 'y': beam['Y']} # TODO: Refactor sideview calculator to match data format + self.sideview_plot.update_curves('reality', data) + surfaces = calc_surfaces(config) + self.surface_plots.update_surfaces(scene='reality', data=surfaces) + + def calc_mo1_energy_resolution(self): + xtal = self.input.mo1_xtal.currentText().translate(str.maketrans('', '', '()')) # Remove brackets from xtal name to conform with parameters + energy = self.input.energy.value() + self.input.mo1_eres.setValue(mo1_energy_resolution(xtal, energy)) + + def calc_cm_reflectivity(self): + cm_stripe = self.input.cm_stripe.currentText() + cm_pitch = -self.input.cm_pitch.value() * 1e-3 + energy = self.input.energy.value() + self.input.cm_refl.setValue(100 * cm_reflectivity(cm_stripe, cm_pitch, energy)) + self.input.cm_refl.setLabel(f"Reflectivity at \n{energy:.0f} eV") + self.input.cm_refl_harm.setValue(100 * cm_reflectivity(cm_stripe, cm_pitch, 3*energy)) + self.input.cm_refl_harm.setLabel(f"Reflectivity at \n{3*energy:.0f} eV") + + def calc_fm_reflectivity(self): + fm_stripe = self.input.fm_stripe.currentText() + fm_focus = self.input.fm_focus.currentText() + if fm_focus in 'Manual': + fm_pitch = -self.input.fm_pitch.value() * 1e-3 + else: + fm_pitch = -self.input.fm_pitch_ideal.value() * 1e-3 + energy = self.input.energy.value() + self.input.fm_refl.setValue(100 * fm_reflectivity(fm_stripe, fm_pitch, energy)) + self.input.fm_refl.setLabel(f"Reflectivity at \n{energy:.0f} eV") + self.input.fm_refl_harm.setValue(100 * fm_reflectivity(fm_stripe, fm_pitch, 3*energy)) + self.input.fm_refl_harm.setLabel(f"Reflectivity at \n{3*energy:.0f} eV") + + def calc_cm_fm_harm_suppr(self): + harm_suppr = (self.input.cm_refl.value() * self.input.fm_refl.value()) / (self.input.cm_refl_harm.value() * self.input.fm_refl_harm.value()) + self.input.cm_fm_harm_suppr.setValue(harm_suppr) + self.input.cm_fm_harm_suppr.setLabel(f"Total Suppression Factor at {3 * self.input.energy.value():.0f} eV") + def calc_assistant_sideview(self): beam = calc_sideview(self.get_assistant_config()) data = {'x': beam['Z'], 'y': beam['Y']} self.sideview_plot.update_curves('assistant', data) - @SafeSlot() def calc_assistant_surfaces(self): surfaces = calc_surfaces(self.get_assistant_config()) self.surface_plots.update_surfaces(scene='assistant', data=surfaces) - @SafeSlot() def calc_positions(self): out = calc_positions(self.get_assistant_config()) - self.positions.sldi_gapx.setValue(out['sldi_gapx']['value']) self.positions.sldi_gapy.setValue(out['sldi_gapy']['value']) self.positions.cm_trx.setValue(out['cm_trx']['value']) @@ -370,7 +328,6 @@ class DigitalTwin(BECWidget, QWidget): self.positions.ot_rotx.setValue(out['ot_rotx']['value']) self.positions.ot_es1_trz.setValue(out['ot_es1_trz']['value']) - @SafeSlot() def calc_mo1_bragg_angle(self): """ Calculates bragg angle in rad @@ -382,29 +339,13 @@ class DigitalTwin(BECWidget, QWidget): d_spacing = self.dev.mo1_bragg.crystal.d_spacing_si311.get() else: raise Exception(f'Invalid xtal selection: {xtal}') - - H = 6.62606957E-34 - E = 1.602176634E-19 - C = 299792458 - - wl = C * H / (E * self.input.energy.value()) - val = wl / (2 * d_spacing * 1e-10) - self.bragg_angle = 0 - if val > -1 and val < 1: - self.bragg_angle = np.asin(val) - cm_pitch = -self.dev.cm_rotx.read()['cm_rotx']['value'] * 1e-3 - if self.input.mo1_mode.currentText() in 'Monochromatic': - # Add 2x CM pitch to the bragg angle - bragg_angle_cor = ((2 * cm_pitch) + self.bragg_angle) / np.pi * 180 - elif self.input.mo1_mode.currentText() in 'Pinkbeam': - # Align xtal surfaces parallel to beam - bragg_angle_cor = (2 * cm_pitch) / np.pi * 180 + mo1_mode = self.input.mo1_mode.currentText() + energy = self.input.energy.value() + theta, theta_cor = mo1_bragg_angle(mo1_mode, d_spacing, energy, cm_pitch) + self.bragg_angle = theta + self.input.mo1_bragg_angle.setValue(theta_cor / np.pi * 180) - self.input.mo1_bragg_angle.setValue(bragg_angle_cor) - # self.calc_positions() - - @SafeSlot() def update_mo1_mode(self): if self.input.mo1_mode.currentText() in 'Monochromatic': self.input.mo1_xtal.setVisible(True) @@ -415,45 +356,22 @@ class DigitalTwin(BECWidget, QWidget): self.input.mo1_bragg_angle.setVisible(False) self.input.mo1_eres.setVisible(False) - @SafeSlot() - def calc_fm_ideal_pitch(self): - p = bl.fm.center[1] # posFM - q = self.input.smpl.value() - bl.fm.center[1] # dist posFM to posEX - if self.input.fm_focus.currentText() in 'Defocused': - a = 2 * np.tan(self.input.sldi_hacc.value() * 1e-3) * bl.fm.center[1] # Beam width at focusing mirror - b = 2 * np.tan(self.input.sldi_vacc.value() * 1e-3) * bl.cm.center[1] # Beam height at focusing mirror (collimated beam) - x = self.input.fm_focx.value() - y = self.input.fm_focy.value() - qx = q + x * p / a - self.qy = q + y * p / b - f = (p * qx) / (p + qx) # focal length - else: # Calculate for focused beam on sample in "manual" and "focused" mode - f = (p * q) / (p + q) # focal length - pitch = 0 - if 'Rh' in self.input.fm_stripe.currentText(): - pitch = np.arcsin(bl.fm.r[0]/(2*f))# ideal pitch for FM - if 'Pt' in self.input.fm_stripe.currentText(): - pitch = np.arcsin(bl.fm.r[1]/(2*f)) # ideal pitch for FM - self.input.fm_pitch_ideal.setValue(-pitch * 1e3) + def calc_fm_ideal_pitch(self): # TODO: What happens if the flats are selected? + fm_focus = self.input.fm_focus.currentText() + fm_stripe = self.input.fm_stripe.currentText() + smpl = self.input.smpl.value() + sldi_hacc = self.input.sldi_hacc.value() * 1e-3 + sldi_vacc = self.input.sldi_vacc.value() * 1e-3 + fm_focx = self.input.fm_focx.value() + fm_focy = self.input.fm_focy.value() + fm_pitch, qy = fm_ideal_pitch(fm_focus, fm_stripe, smpl, sldi_hacc, sldi_vacc, fm_focx, fm_focy) + self.qy = qy + self.input.fm_pitch_ideal.setValue(-fm_pitch * 1e3) - @SafeSlot() def calc_cm_crit_pitch(self): - stripe = self.input.cm_stripe.currentText() - # Config Mirror - if stripe in 'Si': - stripe = bl.stripeSi - elif stripe in 'Pt': - stripe = bl.stripePt - elif stripe in 'Rh': - stripe = bl.stripeRh - else: - raise Exception(f'Stripe {stripe} not found in beamline parameters!') - w = CHeVcm/100/self.input.energy.value() # convert energy [eV] to wavelength [m] - # Calculate critical angle for mirror - f1 = stripe.elements[0].Z + np.real(stripe.elements[0].get_f1f2(self.input.energy.value())) - numberDensity = stripe.rho*1e3*AVOGADRO/(stripe.elements[0].mass/1e3) - criticalAngle = np.sqrt(numberDensity*2.8179e-15*w**2*f1/np.pi) - self.input.cm_pitch_critical.setValue(-criticalAngle * 1e3) + cm_stripe = self.input.cm_stripe.currentText() + energy = self.input.energy.value() + self.input.cm_pitch_critical.setValue(-cm_critical_angle(cm_stripe, energy) * 1e3) class InputPanel(QWidget): """Right-side control panel: input field, indicator, send, recording.""" @@ -720,11 +638,11 @@ class MoverPanel(QWidget): self._layout .addWidget(self.mover_group) self._layout .addStretch() -class SurfacePlots(BECWidget, QWidget): +class SurfacePlots(QWidget): """Plot widget with two curves and legend.""" - def __init__(self, parent=None, *arg, **kwargs): - super().__init__(parent=parent, theme_update=True, *arg, **kwargs) + def __init__(self, parent=None): + super().__init__(parent=parent) self._layout = QHBoxLayout(self) self.surfaces = { @@ -776,7 +694,7 @@ class SurfacePlots(BECWidget, QWidget): # Create surfaces for idx, scene in enumerate(self.surfaces): - for name, device in self.surfaces[scene].items(): + for name, _ in self.surfaces[scene].items(): if scene in 'assistant': brush = QBrush(QColor(*self.colors[idx], 255), Qt.DiagCrossPattern) pen = pg.mkPen(QColor(*self.colors[idx], 255), width=1, style=Qt.DashLine) @@ -830,7 +748,7 @@ class SurfacePlots(BECWidget, QWidget): self.text_color = (0, 0, 0) for idx, scene in enumerate(self.surfaces): - for name, device in self.surfaces[scene].items(): + for name, _ in self.surfaces[scene].items(): if scene in 'assistant': brush = QBrush(QColor(*self.colors[idx], 255), Qt.DiagCrossPattern) pen = pg.mkPen(QColor(*self.colors[idx], 255), width=1, style=Qt.DashLine) @@ -860,7 +778,7 @@ class SurfacePlots(BECWidget, QWidget): rect.setBrush(pg.QtGui.QBrush(pg.QtGui.QColor(*self.color_impenetrable))) # pylint: disable=E1101 rect.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) widget.addItem(rect) - text = pg.TextItem(sf, color=self.text_color, anchor=(0.5, 0.5)) # TODO: CHange color according to theme + text = pg.TextItem(sf, color=self.text_color, anchor=(0.5, 0.5)) widget.addItem(text) text.setPos((hx+lx)/2, (hy+ly)/2) text.setZValue(10) @@ -868,17 +786,17 @@ class SurfacePlots(BECWidget, QWidget): self.texts.append(text) def plot_mono_surface(widget, xtal, xtalWidth, xtalOffsetX, xtalLength): - for sf, w, offx, len in zip(xtal, xtalWidth, xtalOffsetX, xtalLength): + for sf, w, offx, length in zip(xtal, xtalWidth, xtalOffsetX, xtalLength): rect = pg.QtWidgets.QGraphicsRectItem( # pylint: disable=E1101 offx - w/2, - -len/2, + -length/2, w, - len, + length, ) rect.setBrush(pg.QtGui.QBrush(pg.QtGui.QColor(*self.color_impenetrable))) # pylint: disable=E1101 rect.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) widget.addItem(rect) - text = pg.TextItem(sf, color=self.text_color, anchor=(0.5, 0.5)) # TODO: CHange color according to theme + text = pg.TextItem(sf, color=self.text_color, anchor=(0.5, 0.5)) widget.addItem(text) text.setPos(offx, 0) text.setZValue(10) @@ -897,7 +815,7 @@ class SurfacePlots(BECWidget, QWidget): plot_mirror_stripe(plot['widget'], bl.fm.surfaceToroid, bl.fm.limOptXToroid, bl.fm.limOptYToroid) else: raise Exception(f'Plot {name} not found!') - for name, plot in self.plots.items(): + for name, plot in self.plots.items(): plot['widget'].disableAutoRange() def update_surfaces(self, scene, data): @@ -908,11 +826,11 @@ class SurfacePlots(BECWidget, QWidget): y = np.array(device['y'] + [device['y'][0]]) if len(device['y']) != 0 else np.array([]) plot.setData(x=x, y=y) -class SideviewPlot(BECWidget, QWidget): +class SideviewPlot(QWidget): """Plot widget with two curves and legend.""" - def __init__(self, parent=None, *arg, **kwargs): - super().__init__(parent=parent, theme_update=True, *arg, **kwargs) + def __init__(self, parent=None): + super().__init__(parent=parent) self._layout = QVBoxLayout(self) # self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore @@ -1045,7 +963,6 @@ class SideviewPlot(BECWidget, QWidget): if __name__ == "__main__": - from qtpy.QtWidgets import QApplication from bec_widgets.utils.bec_dispatcher import BECDispatcher from bec_widgets.utils.colors import apply_theme @@ -1053,7 +970,5 @@ if __name__ == "__main__": apply_theme("light") dispatcher = BECDispatcher(gui_id="digital_twin") win = DigitalTwin() - - # win.resize(1000, 800) win.show() sys.exit(app.exec_()) diff --git a/debye_bec/bec_widgets/widgets/x01da_parameters.py b/debye_bec/bec_widgets/widgets/x01da_parameters.py index cb5db48..8a97e5a 100644 --- a/debye_bec/bec_widgets/widgets/x01da_parameters.py +++ b/debye_bec/bec_widgets/widgets/x01da_parameters.py @@ -139,7 +139,7 @@ cm = collimatingMirror( material=(stripeSi, stripePt, stripeRh), limPhysX=(-34, 34), limPhysY=(-600, 600), - limOptX=((-27, -3.5, 15), (-11, 6.5, 25)), + limOptX=((-21, -7, 14), (-11, 11, 23)), limOptY=((-500, -500, -500), (500, 500, 500)), R=[3e6, 15e6], pitch=[-5.0e-3, -0.0e-3], From 0365d6eac704c05731760f43a8bdb96a37260d8d Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 7 May 2026 07:32:43 +0200 Subject: [PATCH 43/54] refactoring --- .../widgets/digital_twin/calc_sideview.py | 44 ++++---- .../widgets/digital_twin/calc_varia.py | 97 +++++++++++------ .../widgets/digital_twin/digital_twin.py | 101 ++++++------------ debye_bec/bec_widgets/widgets/qt_widgets.py | 6 +- 4 files changed, 125 insertions(+), 123 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calc_sideview.py b/debye_bec/bec_widgets/widgets/digital_twin/calc_sideview.py index ce97a31..5a0f930 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calc_sideview.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calc_sideview.py @@ -8,34 +8,34 @@ def calc_sideview(cfg): # beam height (Y=height, Z=along beam) beam = {} - beam['Z'] = [] - beam['Y'] = [] - beam['Z'].append(0) # Source - beam['Y'].append(bl.sourceHeight) - beam['Z'].append(bl.cm.center[1]) # CM - beam['Y'].append(bl.sourceHeight) + beam['x'] = [] + beam['y'] = [] + beam['x'].append(0) # Source + beam['y'].append(bl.sourceHeight) + beam['x'].append(bl.cm.center[1]) # CM + beam['y'].append(bl.sourceHeight) if cfg['mo1_mode'] in 'Monochromatic': diag = bl.mo1.xtalGap[0]/np.sin(cfg['mo1_bragg']) # Calculations for Mono dy = diag*np.sin(2*(cfg['cm_pitch']+cfg['mo1_bragg'])) dz = diag*np.cos(2*(cfg['cm_pitch']+cfg['mo1_bragg'])) - beam['Z'].append(bl.mo1.center[1]-dz/2) # Mono 1.1 - beam['Y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.mo1.center[1]-dz/2-bl.cm.center[1])) - beam['Z'].append(bl.mo1.center[1]+dz/2) # Mono 1.2 - beam['Y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.mo1.center[1]-dz/2-bl.cm.center[1])+dy) - beam['Z'].append(bl.fm.center[1]) # FM - beam['Y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1]-dz)+dy) - beam['Z'].append(cfg['smpl']) # Experiment - beam['Y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1]-dz)+dy+np.tan(2*(cfg['cm_pitch']-cfg['fm_pitch']))*(cfg['smpl']-bl.fm.center[1])) + beam['x'].append(bl.mo1.center[1]-dz/2) # Mono 1.1 + beam['y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.mo1.center[1]-dz/2-bl.cm.center[1])) + beam['x'].append(bl.mo1.center[1]+dz/2) # Mono 1.2 + beam['y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.mo1.center[1]-dz/2-bl.cm.center[1])+dy) + beam['x'].append(bl.fm.center[1]) # FM + beam['y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1]-dz)+dy) + beam['x'].append(cfg['smpl']) # Experiment + beam['y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1]-dz)+dy+np.tan(2*(cfg['cm_pitch']-cfg['fm_pitch']))*(cfg['smpl']-bl.fm.center[1])) elif cfg['mo1_mode'] == 'Pinkbeam': - beam['Z'].append(bl.fm.center[1]) # FM - beam['Y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1])) - beam['Z'].append(cfg['smpl']) # Experiment - beam['Y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1])+np.tan(2*(cfg['cm_pitch']-cfg['fm_pitch']))*(cfg['smpl']-bl.fm.center[1])) + beam['x'].append(bl.fm.center[1]) # FM + beam['y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1])) + beam['x'].append(cfg['smpl']) # Experiment + beam['y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1])+np.tan(2*(cfg['cm_pitch']-cfg['fm_pitch']))*(cfg['smpl']-bl.fm.center[1])) - dy_fm_ex = beam['Y'][-1] - beam['Y'][-2] - dz_fm_ex = beam['Z'][-1] - beam['Z'][-2] - dz_fm_win = bl.ehWindow.center[1] - beam['Z'][-2] - h_at_win = beam['Y'][-2] + dy_fm_ex / dz_fm_ex * dz_fm_win + dy_fm_ex = beam['y'][-1] - beam['y'][-2] + dz_fm_ex = beam['x'][-1] - beam['x'][-2] + dz_fm_win = bl.ehWindow.center[1] - beam['x'][-2] + h_at_win = beam['y'][-2] + dy_fm_ex / dz_fm_ex * dz_fm_win beam['heightWindow'] = h_at_win diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calc_varia.py b/debye_bec/bec_widgets/widgets/digital_twin/calc_varia.py index 55bd5e1..11c1c42 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calc_varia.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calc_varia.py @@ -1,43 +1,9 @@ -import sys import re import numpy as np from scipy.interpolate import UnivariateSpline from xrt.backends.raycing.physconsts import CHeVcm, AVOGADRO from bec_lib import bec_logger -# pylint: disable=E0611 -from qtpy.QtWidgets import ( - QWidget, - QVBoxLayout, - QHBoxLayout, - QApplication, - QLayout, -) -# pylint: disable=E0611 -from qtpy.QtCore import ( - Qt, - QTimer, -) -from qtpy.QtGui import ( - QColor, - QBrush, -) -import pyqtgraph as pg - -from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.error_popups import SafeSlot - -from debye_bec.bec_widgets.widgets.qt_widgets import ( - InputNumberField, - ComboBox, - Group, - NumberIndicator, - Mover, -) -from debye_bec.bec_widgets.widgets.digital_twin.calc_positions import calc_positions -from debye_bec.bec_widgets.widgets.digital_twin.calc_sideview import calc_sideview -from debye_bec.bec_widgets.widgets.digital_twin.calc_surfaces import calc_surfaces - import debye_bec.bec_widgets.widgets.x01da_parameters as bl logger = bec_logger.logger @@ -171,3 +137,66 @@ def cm_critical_angle(cm_stripe, energy): numberDensity = stripe.rho*1e3*AVOGADRO/(stripe.elements[0].mass/1e3) criticalAngle = np.sqrt(numberDensity*2.8179e-15*w**2*f1/np.pi) return criticalAngle + + +def mirror_surface_geometries(mirror): + if mirror in "cm": + surface = bl.cm.surface + limOptX = bl.cm.limOptX + limOptY = bl.cm.limOptY + elif mirror in 'fm_toroid': + surface = bl.fm.surfaceToroid + limOptX = bl.fm.limOptXToroid + limOptY = bl.fm.limOptYToroid + elif mirror in 'fm_flat': + surface = bl.fm.surfaceFlat + limOptX = bl.fm.limOptXFlat + limOptY = bl.fm.limOptYFlat + else: + raise ValueError(f'Requested mirror {mirror} not available!') + geom = {} + for sf, lx, hx, ly, hy in zip(surface, limOptX[0], limOptX[1], limOptY[0], limOptY[1]): + geom[sf] = (lx, ly, hx-lx, hy-ly) + return geom + +def mo_surface_geometries(mo, plane): + if mo in 'mo1': + xtal = bl.mo1.xtal + xtal_width = bl.mo1.xtalWidth + xtal_offset_x = bl.mo1.xtalOffsetX + if plane == 0: + xtal_length = bl.mo1.xtalLength1 + else: + xtal_length = bl.mo1.xtalLength2 + else: + raise ValueError(f'Requested mono {mo} not available!') + geom = {} + for sf, w, offx, length in zip(xtal, xtal_width, xtal_offset_x, xtal_length): + geom[sf] = (offx-w/2, -length/2, w, length) + return geom + +def wall_geometries(): + geom = [] + for i, _ in enumerate(bl.walls.start): + geom.append([ + bl.walls.start[i], + bl.walls.height[i][0], + bl.walls.end[i] - bl.walls.start[i], + bl.walls.height[i][1] - bl.walls.height[i][0], + ]) + return geom + +def pipe_geometries(): + pipes = [] + for i, _ in enumerate(bl.vacuum_pipes.center): + top = bl.vacuum_pipes.center[i] + bl.vacuum_pipes.diameter[i]/2 + bl.sourceHeight + bottom = bl.vacuum_pipes.center[i] - bl.vacuum_pipes.diameter[i]/2 + bl.sourceHeight + pipes.append({ + 'x': np.array([bl.vacuum_pipes.start[i], bl.vacuum_pipes.end[i]]), + 'y': np.array([top, top]) + }) + pipes.append({ + 'x': np.array([bl.vacuum_pipes.start[i], bl.vacuum_pipes.end[i]]), + 'y': np.array([bottom, bottom]) + }) + return pipes diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index cabaa96..2326e1d 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -3,10 +3,7 @@ Digital Twin: Custom BEC widget to support the beamline alignment. """ import sys -import re import numpy as np -from scipy.interpolate import UnivariateSpline -from xrt.backends.raycing.physconsts import CHeVcm, AVOGADRO from bec_lib import bec_logger # pylint: disable=E0611 @@ -51,10 +48,12 @@ from debye_bec.bec_widgets.widgets.digital_twin.calc_varia import ( mo1_bragg_angle, fm_ideal_pitch, cm_critical_angle, + mirror_surface_geometries, + mo_surface_geometries, + wall_geometries, + pipe_geometries, ) -import debye_bec.bec_widgets.widgets.x01da_parameters as bl - logger = bec_logger.logger class DigitalTwin(BECWidget, QWidget): @@ -245,21 +244,24 @@ class DigitalTwin(BECWidget, QWidget): self.input.fm_pitch_ideal.setVisible(True) self.input.fm_focx.setVisible(False) self.input.fm_focy.setVisible(False) + self.input.fm_pitch_ideal.setLabel('Incidence Angle for focused beam') elif fm_focus in 'Focused': self.input.fm_pitch.setVisible(False) self.input.fm_pitch_ideal.setVisible(True) self.input.fm_focx.setVisible(False) self.input.fm_focy.setVisible(False) + self.input.fm_pitch_ideal.setLabel('Incidence Angle for focused beam') else: # Defocused self.input.fm_pitch.setVisible(False) self.input.fm_pitch_ideal.setVisible(True) self.input.fm_focx.setVisible(True) self.input.fm_focy.setVisible(True) + self.input.fm_pitch_ideal.setLabel('Incidence Angle for defocused beam') def calc_reality(self): config = self.get_reality_config() beam = calc_sideview(config) - data = {'x': beam['Z'], 'y': beam['Y']} # TODO: Refactor sideview calculator to match data format + data = {'x': beam['x'], 'y': beam['y']} self.sideview_plot.update_curves('reality', data) surfaces = calc_surfaces(config) self.surface_plots.update_surfaces(scene='reality', data=surfaces) @@ -298,7 +300,7 @@ class DigitalTwin(BECWidget, QWidget): def calc_assistant_sideview(self): beam = calc_sideview(self.get_assistant_config()) - data = {'x': beam['Z'], 'y': beam['Y']} + data = {'x': beam['x'], 'y': beam['y']} self.sideview_plot.update_curves('assistant', data) def calc_assistant_surfaces(self): @@ -382,11 +384,11 @@ class InputPanel(QWidget): self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore # Energy - self.energy = InputNumberField('energy', 'Energy [eV]', init=8979, decimals=0, single_step=100, ll=4000, hl=65000) + self.energy = InputNumberField('energy', 'Energy', unit='eV', init=8979, decimals=0, single_step=100, ll=4000, hl=65000) # FE Slits Acceptance - self.sldi_hacc = InputNumberField('h_acc', 'Horizontal [± mrad]', init=0.25, decimals=2, single_step=0.01, ll=-0.1, hl=0.9) - self.sldi_vacc = InputNumberField('v_acc', 'Vertical [± mrad]', init=0.1, decimals=2, single_step=0.01, ll=-0.1, hl=0.5) + self.sldi_hacc = InputNumberField('h_acc', 'Horizontal', unit='mrad', prefix='±', init=0.25, decimals=2, single_step=0.01, ll=-0.1, hl=0.9) + self.sldi_vacc = InputNumberField('v_acc', 'Vertical', unit='mrad', prefix='±', init=0.1, decimals=2, single_step=0.01, ll=-0.1, hl=0.5) self.sldi_ass_group = Group( 'FE Slits Acceptance', [ @@ -397,7 +399,7 @@ class InputPanel(QWidget): # Collimating mirror self.cm_stripe = ComboBox('cm_stripe', 'Stripe', ['Si', 'Rh', 'Pt']) - self.cm_pitch = InputNumberField('cm_pitch', 'Pitch [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-4.6, hl=-1.2) + self.cm_pitch = InputNumberField('cm_pitch', 'Pitch', unit='mrad', init=-2.391, decimals=3, single_step=0.01, ll=-4.6, hl=-1.2) self.cm_pitch_critical = NumberIndicator('Critical Pitch', 'mrad', decimals=3) self.cm_refl = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.cm_refl_harm = NumberIndicator('Reflectivity at x eV', '%', decimals=0) @@ -430,9 +432,9 @@ class InputPanel(QWidget): # Focusing Mirror self.fm_stripe = ComboBox('fm_stripe', 'Stripe', ['Rh (toroid)', 'Rh (flat)', 'Pt (toroid)', 'Pt (flat)']) self.fm_focus = ComboBox('fm_focus', 'Focus Type', ['Manual', 'Focused', 'Defocused']) - self.fm_pitch = InputNumberField('fm_pitch', 'Incidence Angle [mrad]', init=-2.391, decimals=3, single_step=0.01, ll=-10, hl=2) - self.fm_focx = InputNumberField('fm_focx', 'Beam Size Horizontal [mm]', init=1, decimals=1, single_step=0.1, ll=0, hl=30) - self.fm_focy = InputNumberField('fm_focy', 'Beam Size Vertical [mm]', init=1, decimals=1, single_step=0.1, ll=0, hl=10) + self.fm_pitch = InputNumberField('fm_pitch', 'Incidence Angle', unit='mrad', init=-2.391, decimals=3, single_step=0.01, ll=-10, hl=2) + self.fm_focx = InputNumberField('fm_focx', 'Beam Size Horizontal', unit='mm', init=1, decimals=1, single_step=0.1, ll=0, hl=30) + self.fm_focy = InputNumberField('fm_focy', 'Beam Size Vertical', unit='mm', init=1, decimals=1, single_step=0.1, ll=0, hl=10) self.fm_pitch_ideal = NumberIndicator('Incidence Angle for focused beam', 'mrad', decimals=3) self.fm_refl = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.fm_refl_harm = NumberIndicator('Reflectivity at x eV', '%', decimals=0) @@ -452,7 +454,7 @@ class InputPanel(QWidget): # Sample self.cm_fm_harm_suppr = NumberIndicator('Total Suppression Factor at x eV', '', decimals=0) - self.smpl = InputNumberField('smpl', 'Sample Position [mm]', init=23511, decimals=0, single_step=100, ll=23000, hl=30000) + self.smpl = InputNumberField('smpl', 'Sample Position', unit='mm', init=23511, decimals=0, single_step=100, ll=23000, hl=30000) # Assemble complete assitant group self.input_group = Group( @@ -767,52 +769,29 @@ class SurfacePlots(QWidget): def plot_walls(self): - def plot_mirror_stripe(widget, surface, limOptX, limOptY): - for sf, lx, hx, ly, hy in zip(surface, limOptX[0], limOptX[1], limOptY[0], limOptY[1]): - rect = pg.QtWidgets.QGraphicsRectItem( # pylint: disable=E1101 - lx, - ly, - hx - lx, - hy - ly, - ) + def plot_surface(widget, surfaces): + for name, surface in surfaces.items(): + rect = pg.QtWidgets.QGraphicsRectItem(*surface) # pylint: disable=E1101 rect.setBrush(pg.QtGui.QBrush(pg.QtGui.QColor(*self.color_impenetrable))) # pylint: disable=E1101 rect.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) widget.addItem(rect) - text = pg.TextItem(sf, color=self.text_color, anchor=(0.5, 0.5)) + text = pg.TextItem(name, color=self.text_color, anchor=(0.5, 0.5)) widget.addItem(text) - text.setPos((hx+lx)/2, (hy+ly)/2) - text.setZValue(10) - self.walls.append(rect) - self.texts.append(text) - - def plot_mono_surface(widget, xtal, xtalWidth, xtalOffsetX, xtalLength): - for sf, w, offx, length in zip(xtal, xtalWidth, xtalOffsetX, xtalLength): - rect = pg.QtWidgets.QGraphicsRectItem( # pylint: disable=E1101 - offx - w/2, - -length/2, - w, - length, - ) - rect.setBrush(pg.QtGui.QBrush(pg.QtGui.QColor(*self.color_impenetrable))) # pylint: disable=E1101 - rect.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) - widget.addItem(rect) - text = pg.TextItem(sf, color=self.text_color, anchor=(0.5, 0.5)) - widget.addItem(text) - text.setPos(offx, 0) + text.setPos(surface[0]+surface[2]/2, surface[1]+surface[3]/2) text.setZValue(10) self.walls.append(rect) self.texts.append(text) for name, plot in self.plots.items(): if name in 'cm': - plot_mirror_stripe(plot['widget'], bl.cm.surface, bl.cm.limOptX, bl.cm.limOptY) + plot_surface(plot['widget'], mirror_surface_geometries('cm')) elif name in 'mo1_1': - plot_mono_surface(plot['widget'], bl.mo1.xtal, bl.mo1.xtalWidth, bl.mo1.xtalOffsetX, bl.mo1.xtalLength1) + plot_surface(plot['widget'], mo_surface_geometries ('mo1', 0)) elif name in 'mo1_2': - plot_mono_surface(plot['widget'], bl.mo1.xtal, bl.mo1.xtalWidth, bl.mo1.xtalOffsetX, bl.mo1.xtalLength2) + plot_surface(plot['widget'], mo_surface_geometries ('mo1', 1)) elif name in 'fm': - plot_mirror_stripe(plot['widget'], bl.fm.surfaceFlat, bl.fm.limOptXFlat, bl.fm.limOptYFlat) - plot_mirror_stripe(plot['widget'], bl.fm.surfaceToroid, bl.fm.limOptXToroid, bl.fm.limOptYToroid) + plot_surface(plot['widget'], mirror_surface_geometries('fm_flat')) + plot_surface(plot['widget'], mirror_surface_geometries('fm_toroid')) else: raise Exception(f'Plot {name} not found!') for name, plot in self.plots.items(): @@ -929,32 +908,22 @@ class SideviewPlot(QWidget): pipe.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) def plot_vacuum_pipes(self): - for i, _ in enumerate(bl.vacuum_pipes.center): - top = bl.vacuum_pipes.center[i] + bl.vacuum_pipes.diameter[i]/2 + bl.sourceHeight - bottom = bl.vacuum_pipes.center[i] - bl.vacuum_pipes.diameter[i]/2 + bl.sourceHeight + pipes = pipe_geometries() + for pipe in pipes: self.pipes.append(self.plot_widget.plot( - x=np.array([bl.vacuum_pipes.start[i], bl.vacuum_pipes.end[i]]), - y=np.array([top, top]), - pen=pg.mkPen(color=self.color_impenetrable, width=2), - )) - self.pipes.append(self.plot_widget.plot( - x=np.array([bl.vacuum_pipes.start[i], bl.vacuum_pipes.end[i]]), - y=np.array([bottom, bottom]), + x=pipe['x'], + y=pipe['y'], pen=pg.mkPen(color=self.color_impenetrable, width=2), )) def plot_walls(self): - for i, _ in enumerate(bl.walls.start): - rect = pg.QtWidgets.QGraphicsRectItem( # pylint: disable=E1101 - bl.walls.start[i], - bl.walls.height[i][0], - bl.walls.end[i] - bl.walls.start[i], - bl.walls.height[i][1] - bl.walls.height[i][0], - ) + walls = wall_geometries() + for wall in walls: + rect = pg.QtWidgets.QGraphicsRectItem(*wall) # pylint: disable=E1101 rect.setBrush(pg.QtGui.QBrush(pg.QtGui.QColor(*self.color_impenetrable))) # pylint: disable=E1101 rect.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) self.plot_widget.addItem(rect) - self.walls.append(rect) + self.walls.append(rect) def update_curves(self, scene, data): self.data[scene] = data diff --git a/debye_bec/bec_widgets/widgets/qt_widgets.py b/debye_bec/bec_widgets/widgets/qt_widgets.py index 3791faf..c5f2057 100644 --- a/debye_bec/bec_widgets/widgets/qt_widgets.py +++ b/debye_bec/bec_widgets/widgets/qt_widgets.py @@ -113,7 +113,7 @@ class NumberIndicator(QWidget): # ) class InputNumberField(QWidget): - def __init__(self, identifier='', label='', init=0.0, decimals=1, single_step=0.1, ll=-1e6, hl=1e6): + def __init__(self, identifier='', label='', unit=None, prefix=None, init=0.0, decimals=1, single_step=0.1, ll=-1e6, hl=1e6): super().__init__() layout = QHBoxLayout(self) layout.setContentsMargins(10, 0, 0, 0) @@ -129,6 +129,10 @@ class InputNumberField(QWidget): self.val.setDecimals(decimals) self.val.setSingleStep(single_step) self.val.setValue(init) + if unit is not None: + self.val.setSuffix(' ' + unit) + if prefix is not None: + self.val.setPrefix(prefix + ' ') # self.val.setFixedWidth(140) layout.addWidget(self.val) From 8493b60468e2c1773223454981aa07fc1e69d8f2 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 7 May 2026 14:52:54 +0200 Subject: [PATCH 44/54] wip: digital twin --- .../widgets/digital_twin/digital_twin.py | 259 ++++++++-- .../widgets/digital_twin/move_widget.py | 448 ++++++++++++++++++ debye_bec/bec_widgets/widgets/qt_widgets.py | 234 ++++----- 3 files changed, 797 insertions(+), 144 deletions(-) create mode 100644 debye_bec/bec_widgets/widgets/digital_twin/move_widget.py diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 2326e1d..5c1ab1e 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -5,6 +5,7 @@ Digital Twin: Custom BEC widget to support the beamline alignment. import sys import numpy as np from bec_lib import bec_logger +from bec_lib.endpoints import MessageEndpoints # pylint: disable=E0611 from qtpy.QtWidgets import ( @@ -22,6 +23,7 @@ from qtpy.QtCore import ( from qtpy.QtGui import ( QColor, QBrush, + QCloseEvent, ) import pyqtgraph as pg @@ -33,8 +35,8 @@ from debye_bec.bec_widgets.widgets.qt_widgets import ( ComboBox, Group, NumberIndicator, - Mover, ) +from debye_bec.bec_widgets.widgets.digital_twin.move_widget import MoveWidget from debye_bec.bec_widgets.widgets.digital_twin.calc_positions import calc_positions from debye_bec.bec_widgets.widgets.digital_twin.calc_sideview import calc_sideview from debye_bec.bec_widgets.widgets.digital_twin.calc_surfaces import calc_surfaces @@ -77,14 +79,14 @@ class DigitalTwin(BECWidget, QWidget): self.sideview_plot = SideviewPlot() self.surface_plots = SurfacePlots() self.positions = PositionsPanel() - # self.mover = MoverPanel(dev=self.dev) + self.mover = MoverPanel(self.dev) self.root_layout.addWidget(self.input, stretch=1, alignment=Qt.AlignTop) # type: ignore self.plot_layout.addWidget(self.sideview_plot) # type: ignore self.plot_layout.addWidget(self.surface_plots) # type: ignore self.root_layout.addWidget(self.plot_widget, stretch=1, alignment=Qt.AlignTop) # type: ignore - self.root_layout.addWidget(self.positions, stretch=1, alignment=Qt.AlignTop) # type: ignore - # self.root_layout.addWidget(self.mover, stretch=1, alignment=Qt.AlignTop) # type: ignore + # self.root_layout.addWidget(self.positions, stretch=1, alignment=Qt.AlignTop) # type: ignore + self.root_layout.addWidget(self.mover, stretch=1, alignment=Qt.AlignTop) self.setLayout(self.root_layout) self.setWindowTitle("Digital Twin") @@ -112,13 +114,14 @@ class DigitalTwin(BECWidget, QWidget): # Timer: update plot every 1 second self._timer = QTimer(self) - self._timer.setInterval(1000) + self._timer.setInterval(100) self._timer.timeout.connect(self.calc_reality) self._timer.start() def apply_theme(self, theme): self.sideview_plot.apply_theme(theme) self.surface_plots.apply_theme(theme) + self.mover.apply_theme(theme) @SafeSlot() def calc_assistant(self, *args, **kwargs): @@ -203,38 +206,60 @@ class DigitalTwin(BECWidget, QWidget): return config def get_reality_config(self): - if abs(self.dev.mo1_trx.read()['mo1_trx']['value']) > 5: + mo1_trx = self.dev.mo1_trx.read(cached=True)['mo1_trx']['value'] + if abs(mo1_trx) > 5: mo1_mode = 'Monochromatic' else: mo1_mode = 'Pinkbeam' - mo1_bragg = self.dev.mo1_bragg.read() - sldi_gapx = self.dev.sldi_gapx.read()['sldi_gapx']['value'] - sldi_gapy = self.dev.sldi_gapy.read()['sldi_gapy']['value'] + mo1_bragg = self.dev.mo1_bragg.read(cached=True) + sldi_gapx = self.dev.sldi_gapx.read(cached=True)['sldi_gapx']['value'] + sldi_gapy = self.dev.sldi_gapy.read(cached=True)['sldi_gapy']['value'] h_acc, v_acc = sldi_gap_to_acc(sldi_gapx, sldi_gapy) - cm_trx = -self.dev.cm_trx.read()['cm_trx']['value'] + cm_trx = -self.dev.cm_trx.read(cached=True)['cm_trx']['value'] cm_stripe = cm_trx_to_stripe(cm_trx) - cm_pitch = -self.dev.cm_rotx.read()['cm_rotx']['value'] * 1e-3 - fm_trx = -self.dev.fm_trx.read()['fm_trx']['value'] + cm_pitch = self.dev.cm_rotx.read(cached=True)['cm_rotx']['value'] + fm_trx = -self.dev.fm_trx.read(cached=True)['fm_trx']['value'] fm_stripe = fm_trx_to_stripe(fm_trx) - fm_pitch = -self.dev.fm_rotx.read()['fm_rotx']['value'] * 1e-3 + fm_pitch = self.dev.fm_rotx.read(cached=True)['fm_rotx']['value'] fm_pitch_real = 2 * cm_pitch - fm_pitch + smpl = self.dev.ot_es1_trz.read(cached=True)['ot_es1_trz']['value'] config = { # Config in SI units! 'energy' : mo1_bragg['mo1_bragg']['value'], 'h_acc' : h_acc, 'v_acc' : v_acc, - 'cm_pitch' : cm_pitch, + 'cm_pitch' : -cm_pitch * 1e-3, 'cm_stripe' : cm_stripe, 'cm_trx' : cm_trx, 'mo1_mode' : mo1_mode, 'mo1_xtal' : mo1_bragg['mo1_bragg_crystal_current_xtal_string']['value'], 'mo1_bragg' : mo1_bragg['mo1_bragg_angle']['value']/180*np.pi, - 'fm_pitch' : fm_pitch_real, + 'fm_pitch' : -fm_pitch_real * 1e-3, 'fm_stripe' : fm_stripe, 'fm_trx' : fm_trx, 'fm_gain_height' : 1, - 'smpl' : self.dev.ot_es1_trz.read()['ot_es1_trz']['value'], + 'smpl' : smpl, } # logger.info(f'Config created: {config}') + self.mover.sldi_gapx.set_feedback(sldi_gapx) + self.mover.sldi_gapy.set_feedback(sldi_gapy) + self.mover.cm_trx.set_feedback(cm_trx) + self.mover.cm_try.set_feedback(self.dev.cm_try.read(cached=True)['cm_try']['value']) + self.mover.cm_bnd.set_feedback(self.dev.cm_bnd_radius.read(cached=True)['cm_bnd_radius']['value']) + self.mover.cm_rotx.set_feedback(cm_pitch) + self.mover.mo1_bragg_angle.set_feedback(mo1_bragg['mo1_bragg_angle']['value']) + self.mover.mo1_trx.set_feedback(mo1_trx) + self.mover.mo1_try.set_feedback(self.dev.mo1_try.read(cached=True)['mo1_try']['value']) + self.mover.sl1_centery.set_feedback(self.dev.sl1_centery.read(cached=True)['sl1_centery']['value']) + self.mover.bm1_try.set_feedback(self.dev.bm1_try.read(cached=True)['bm1_try']['value']) + self.mover.fm_trx.set_feedback(fm_trx) + self.mover.fm_try.set_feedback(self.dev.fm_try.read(cached=True)['fm_try']['value']) + self.mover.fm_bnd.set_feedback(self.dev.fm_bnd_radius.read(cached=True)['fm_bnd_radius']['value']) + self.mover.fm_rotx.set_feedback(fm_pitch) + self.mover.sl2_centery.set_feedback(self.dev.sl2_centery.read(cached=True)['sl2_centery']['value']) + self.mover.bm2_try.set_feedback(self.dev.bm2_try.read(cached=True)['bm2_try']['value']) + self.mover.ot_try.set_feedback(self.dev.ot_try.read(cached=True)['ot_try']['value']) + self.mover.ot_rotx.set_feedback(self.dev.ot_rotx.read(cached=True)['ot_rotx']['value']) + self.mover.ot_es1_trz.set_feedback(smpl) return config def update_fm_mode(self): @@ -330,18 +355,39 @@ class DigitalTwin(BECWidget, QWidget): self.positions.ot_rotx.setValue(out['ot_rotx']['value']) self.positions.ot_es1_trz.setValue(out['ot_es1_trz']['value']) + self.mover.sldi_gapx.set_target(out['sldi_gapx']['value']) + self.mover.sldi_gapy.set_target(out['sldi_gapy']['value']) + self.mover.cm_trx.set_target(out['cm_trx']['value']) + self.mover.cm_try.set_target(out['cm_try']['value']) + self.mover.cm_bnd.set_target(out['cm_bnd_radius']['value']) + self.mover.cm_rotx.set_target(out['cm_rotx']['value']) + self.mover.mo1_bragg_angle.set_target(out['mo1_bragg_angle']['value']) + self.mover.mo1_trx.set_target(out['mo1_trx']['value']) + self.mover.mo1_try.set_target(out['mo1_try']['value']) + self.mover.sl1_centery.set_target(out['sl1_centery']['value']) + self.mover.bm1_try.set_target(out['bm1_try']['value']) + self.mover.fm_trx.set_target(out['fm_trx']['value']) + self.mover.fm_try.set_target(out['fm_try']['value']) + self.mover.fm_bnd.set_target(out['fm_bnd_radius']['value']) + self.mover.fm_rotx.set_target(out['fm_rotx']['value']) + self.mover.sl2_centery.set_target(out['sl2_centery']['value']) + self.mover.bm2_try.set_target(out['bm2_try']['value']) + self.mover.ot_try.set_target(out['ot_try']['value']) + self.mover.ot_rotx.set_target(out['ot_rotx']['value']) + self.mover.ot_es1_trz.set_target(out['ot_es1_trz']['value']) + def calc_mo1_bragg_angle(self): """ Calculates bragg angle in rad """ xtal = self.input.mo1_xtal.currentText() if xtal in 'Si(111)': - d_spacing = self.dev.mo1_bragg.crystal.d_spacing_si111.get() + d_spacing = self.dev.mo1_bragg.crystal.d_spacing_si111.read(cached=True)['mo1_bragg_crystal_d_spacing_si111']['value'] elif xtal in 'Si(311)': - d_spacing = self.dev.mo1_bragg.crystal.d_spacing_si311.get() + d_spacing = self.dev.mo1_bragg.crystal.d_spacing_si311.read(cached=True)['mo1_bragg_crystal_d_spacing_si311']['value'] else: raise Exception(f'Invalid xtal selection: {xtal}') - cm_pitch = -self.dev.cm_rotx.read()['cm_rotx']['value'] * 1e-3 + cm_pitch = -self.dev.cm_rotx.read(cached=True)['cm_rotx']['value'] * 1e-3 mo1_mode = self.input.mo1_mode.currentText() energy = self.input.energy.value() theta, theta_cor = mo1_bragg_angle(mo1_mode, d_spacing, energy, cm_pitch) @@ -607,19 +653,20 @@ class MoverPanel(QWidget): def __init__(self, dev, parent=None): super().__init__(parent) - self.dev = dev self._layout = QVBoxLayout(self) self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore + self.mover_widgets = [] + self.dev = dev + + # FE Slits mot = self.dev.sldi_gapx - egu = self.dev.sldi_gapx.egu() - prec = self.dev.sldi_gapx.precision - self.sldi_gapx = Mover(mot, egu, prec) + self.sldi_gapx = MoveWidget(motor=mot, label='GAPX', unit='mm', decimals=2) + self.mover_widgets.append(self.sldi_gapx) mot = self.dev.sldi_gapy - egu = self.dev.sldi_gapy.egu() - prec = self.dev.sldi_gapy.precision - self.sldi_gapy = Mover(mot, egu, prec) + self.sldi_gapy = MoveWidget(motor=mot, label='GAPY', unit='mm', decimals=2) + self.mover_widgets.append(self.sldi_gapy) self.sldi_mov_group = Group( 'FE Slits', @@ -629,16 +676,174 @@ class MoverPanel(QWidget): ] ) - # Assemble complete assitant group + # Collimating mirror + mot = self.dev.cm_trx + self.cm_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2) + self.mover_widgets.append(self.cm_trx) + + mot = self.dev.cm_try + self.cm_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2) + self.mover_widgets.append(self.cm_try) + + mot = self.dev.cm_bnd + self.cm_bnd = MoveWidget(motor=mot, label='BENDER', unit='km', decimals=2) + self.mover_widgets.append(self.cm_bnd) + + mot = self.dev.cm_rotx + self.cm_rotx = MoveWidget(motor=mot, label='PITCH', unit='mrad', decimals=3) + self.mover_widgets.append(self.cm_rotx) + + self.cm_mov_group = Group( + 'Collimating Mirror', + [ + self.cm_trx, + self.cm_try, + self.cm_bnd, + self.cm_rotx, + ] + ) + + # Monochromator + mot = self.dev.mo1_bragg + self.mo1_bragg_angle = MoveWidget(motor=mot, label='Bragg Angle', unit='deg', decimals=3) + self.mover_widgets.append(self.mo1_bragg_angle) + + mot = self.dev.mo1_trx + self.mo1_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2) + self.mover_widgets.append(self.mo1_trx) + + mot = self.dev.mo1_try + self.mo1_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2) + self.mover_widgets.append(self.mo1_try) + + self.mo1_mov_group = Group( + 'Monochromator', + [ + self.mo1_bragg_angle, + self.mo1_trx, + self.mo1_try, + ] + ) + + # OP Slits 1 + mot = self.dev.sl1_centery + self.sl1_centery = MoveWidget(motor=mot, label='CENTERY', unit='mm', decimals=2) + self.mover_widgets.append(self.sl1_centery) + + self.sl1_mov_group = Group( + 'OP Slits 1', + [ + self.sl1_centery, + ] + ) + + # OP Beam Monitor 1 + mot = self.dev.bm1_try + self.bm1_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2) + self.mover_widgets.append(self.bm1_try) + + self.bm1_mov_group = Group( + 'OP Beam Monitor 1', + [ + self.bm1_try, + ] + ) + + # Focusing Mirror + mot = self.dev.fm_trx + self.fm_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2) + self.mover_widgets.append(self.fm_trx) + + mot = self.dev.fm_try + self.fm_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2) + self.mover_widgets.append(self.fm_try) + + mot = self.dev.fm_bnd + self.fm_bnd = MoveWidget(motor=mot, label='BENDER', unit='km', decimals=2) + self.mover_widgets.append(self.fm_bnd) + + mot = self.dev.fm_rotx + self.fm_rotx = MoveWidget(motor=mot, label='PITCH', unit='mrad', decimals=3) + self.mover_widgets.append(self.fm_rotx) + + self.fm_mov_group = Group( + 'Focusing Mirror', + [ + self.fm_trx, + self.fm_try, + self.fm_bnd, + self.fm_rotx, + ] + ) + + # OP Slits 2 + mot = self.dev.sl2_centery + self.sl2_centery = MoveWidget(motor=mot, label='CENTERY', unit='mm', decimals=2) + self.mover_widgets.append(self.sl2_centery) + + self.sl2_mov_group = Group( + 'OP Slits 2', + [ + self.sl2_centery, + ] + ) + + # OP Beam Monitor 2 + mot = self.dev.bm2_try + self.bm2_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2) + self.mover_widgets.append(self.bm2_try) + + self.bm2_mov_group = Group( + 'OP Beam Monitor 2', + [ + self.bm2_try, + ] + ) + + # Optical Table + mot = self.dev.ot_try + self.ot_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2) + self.mover_widgets.append(self.ot_try) + + mot = self.dev.ot_rotx + self.ot_rotx = MoveWidget(motor=mot, label='ROTX', unit='mrad', decimals=3) + self.mover_widgets.append(self.ot_rotx) + + mot = self.dev.ot_es1_trz + self.ot_es1_trz = MoveWidget(motor=mot, label='ES1 TRZ', unit='mm', decimals=0) + self.mover_widgets.append(self.ot_es1_trz) + + self.ot_mov_group = Group( + 'Optical Table', + [ + self.ot_try, + self.ot_rotx, + self.ot_es1_trz, + ] + ) + + # Assemble complete mover group self.mover_group = Group( 'Mover', [ self.sldi_mov_group, + self.cm_mov_group, + self.mo1_mov_group, + self.sl1_mov_group, + self.bm1_mov_group, + self.fm_mov_group, + self.sl2_mov_group, + self.bm2_mov_group, + self.ot_mov_group, ] ) self._layout .addWidget(self.mover_group) self._layout .addStretch() + + def apply_theme(self, theme): + for widget in self.mover_widgets: + widget.apply_theme(theme) class SurfacePlots(QWidget): """Plot widget with two curves and legend.""" diff --git a/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py b/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py new file mode 100644 index 0000000..2067042 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py @@ -0,0 +1,448 @@ +import time +import random +import threading + +# import qtawesome as qta +from bec_qthemes import material_icon +from bec_widgets.utils.colors import get_accent_colors +from bec_lib import bec_logger + +from qtpy.QtCore import Qt, QThread, Signal, QObject, Property, QPropertyAnimation +from qtpy.QtWidgets import ( + QGroupBox, QHBoxLayout, QVBoxLayout, QLabel, QPushButton, + QDoubleSpinBox, QFrame, QWidget, QApplication +) +from qtpy.QtGui import QTransform + +logger = bec_logger.logger + +class Status: + IN_POSITION = "in_position" # green mdi.check-circle + NOT_IN_POSITION = "not_in_position" # orange mdi.close-circle + MOVING = "moving" # blue mdi.loading (spinning) + ERROR = "error" # red mdi.alert-circle + +# class StatusIcon(qta.IconWidget): +# """ +# Displays a status icon using qtawesome Material Design Icons. +# Handles its own spin animation for the MOVING state via qta.Spin. +# """ + +# ICON_SIZE = 36 + +# # Map each status to an (icon_name, color) pair +# _ICON_MAP = { +# Status.IN_POSITION: ("mdi.check-circle-outline", "#27ae60"), +# Status.NOT_IN_POSITION: ("mdi.close-circle-outline", "#e6d922"), +# Status.ERROR: ("mdi.alert-outline", "#e74c3c"), +# Status.MOVING: ("mdi.gamepad-circle-outline", "#2980b9"), +# } + +# def __init__(self, parent=None): +# super().__init__(parent=parent) +# self._status = None +# self._spin_anim = qta.Spin(self, autostart=True) +# self.setFixedSize(self.ICON_SIZE, self.ICON_SIZE) +# self.set_status(Status.NOT_IN_POSITION) + +# def set_status(self, status: str): +# if status == self._status: +# return +# self._status = status + +# icon_name, color = self._ICON_MAP[status] + +# if status == Status.MOVING: +# icon = qta.icon(icon_name, color=color, animation=self._spin_anim) +# self._spin_anim.start() +# else: +# self._spin_anim.stop() +# icon = qta.icon(icon_name, color=color) + +# self.setIcon(icon) +# self.setIconSize(QSize(self.ICON_SIZE, self.ICON_SIZE)) + + +class StatusIcon(QWidget): + """ + Displays a status icon using bec_qthemes Material Design Icons. + Handles its own spin animation for the MOVING state via QPropertyAnimation. + """ + + ICON_SIZE = 20 + + _ICON_MAP = { + Status.IN_POSITION: ("check_circle", "#27ae60"), + Status.NOT_IN_POSITION: ("cancel", "#e6d922"), + Status.ERROR: ("warning", "#e74c3c"), + Status.MOVING: ("cycle", "#2980b9"), + } + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._status = None + self._rotation = 0.0 + + self._label = QLabel(self) + self._label.setFixedSize(self.ICON_SIZE, self.ICON_SIZE) + self._label.setAlignment(Qt.AlignCenter) + self.setFixedSize(self.ICON_SIZE, self.ICON_SIZE) + + self._spin_anim = QPropertyAnimation(self, b"rotation") + self._spin_anim.setStartValue(0) + self._spin_anim.setEndValue(360) + self._spin_anim.setDuration(1000) + self._spin_anim.setLoopCount(-1) # Loop indefinitely + + self.set_status(Status.NOT_IN_POSITION) + + def get_rotation(self): + return self._rotation + + def set_rotation(self, angle): + self._rotation = angle + if self._current_pixmap_base is not None: + cx = self._current_pixmap_base.width() / 2 + cy = self._current_pixmap_base.height() / 2 + t = QTransform().translate(cx, cy).rotate(angle).translate(-cx, -cy) + self._label.setPixmap(self._current_pixmap_base.transformed(t, Qt.SmoothTransformation)) + + rotation = Property(float, get_rotation, set_rotation) + + def set_status(self, status: str): + if status == self._status: + return + self._status = status + + icon_name, color = self._ICON_MAP[status] + icon = material_icon(icon_name, size=(self.ICON_SIZE, self.ICON_SIZE), color=color, convert_to_pixmap=True) + self._current_pixmap_base = icon + + if status == Status.MOVING: + self._spin_anim.start() + else: + self._spin_anim.stop() + self._label.setPixmap(icon) + +class MotionWorker(QObject): + """ + Simulates moving a stage from current_pos to target_pos. + Emits position_changed and finished signals. + """ + position_changed = Signal(float) + error = Signal(bool) # True = error + finished = Signal(bool) # True = reached target, False = stopped + + def __init__(self, motor, target_pos: float): + super().__init__() + self.motor = motor + self.name = motor.dotted_name + self._target = target_pos + self._stop_flag = threading.Event() + + def stop(self): + self._stop_flag.set() + + def run(self): + logger.info(f'Would run motor {self.name}') + simulated_run_time = 3 + start = time.time() + while (time.time() - start) < simulated_run_time: + if self._stop_flag.is_set(): + break + time.sleep(0.01) + + # self.motor.move(self._target, relative=False) + # while self.motor.motor_is_moving.get(): + # if self._stop_flag.is_set(): + # self.motor.motor_stop() + # self.position_changed.emit(self.motor.read[self.name]['value']) + # time.sleep(0.1) + self.finished.emit(True) + + # def run(self): + # """Simulate motion: move in small steps with realistic deceleration.""" + # distance = abs(self._target - self._current) + # direction = 1 if self._target > self._current else -1 + # speed = max(0.5, distance / 3.0) # units/second (simulated) + # step_time = 0.05 # seconds per step + + # pos = self._current + # while not self._stop_flag.is_set(): + # remaining = abs(self._target - pos) + # if remaining < 0.01: + # pos = self._target + # self.position_changed.emit(round(pos, 4)) + # self.finished.emit(True) + # return + + # # Decelerate near the end + # effective_speed = speed * min(1.0, remaining / (distance * 0.2 + 0.001)) + # effective_speed = max(0.05, effective_speed) + # step = effective_speed * step_time * direction + # if abs(step) > remaining: + # step = remaining * direction + + # pos += step + # pos += random.gauss(0, 0.002) # tiny simulated encoder noise + # if pos > 20: # Simulated error if above 20 mm + # self.error.emit(True) + # return + # self.position_changed.emit(round(pos, 4)) + # time.sleep(step_time) + + # # Stopped by user + # self.position_changed.emit(round(pos, 4)) + # self.finished.emit(False) + +class MoveWidget(QWidget): + """ + One motor stage control group containing: + - Value spinbox (target position) + - Feedback label (current position) + - Status icon (qtawesome) + - Start / Stop button + """ + + DEADBAND = 0.02 # mm — positions within this tolerance are "in position" + + def __init__(self, motor, label: str = '', unit=None, decimals=3): + super().__init__() + self.fb = 0.0 + self.target = 0 + self.motor = motor + self._status = Status.IN_POSITION + self._thread: QThread | None = None + self._worker: MotionWorker | None = None + + self.text_color = (0, 0, 0) + + self.unit = unit + self.decimals = decimals + + # self._set_status(Status.IN_POSITION) + + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 0, 0, 0) + layout.setSpacing(0) + + # Name + self.label = QLabel(label) + self.label.setFixedWidth(100) + self.label.setContentsMargins(0, 0, 10, 0) + self.label.setWordWrap(True) + layout.addWidget(self.label) + + # Target + self.target_label = QLabel('-') + self.target_label.setFixedWidth(100) + layout.addWidget(self.target_label) + + # Feedback + self.fb_label = QLabel('-') + self.fb_label.setFixedWidth(100) + layout.addWidget(self.fb_label) + + # Status icon + self.status_icon = StatusIcon() + self.status_icon.setFixedWidth(30) + self.status_icon.setContentsMargins(0, 0, 10, 0) + layout.addWidget(self.status_icon) + + # Start / Stop button + self.btn_action = QPushButton("▶ Move") + self.btn_action.setFixedWidth(90) + self.btn_action.setFixedHeight(20) + self.btn_action.clicked.connect(self._on_button_clicked) + layout.addWidget(self.btn_action) + + self._apply_button_style("start") + + self.apply_theme() + + def apply_theme(self, theme=None): + if theme is None: + app = QApplication.instance() + theme = app.theme.theme # type: ignore + + if theme == "light": + self.text_color = {'target': (79, 163, 224), 'fb': (240, 128, 60)} + else: # dark theme + self.text_color = {'target': (26, 111, 173), 'fb': (212, 83, 10)} + r, g, b = self.text_color['target'] + self.target_label.setStyleSheet(f'QLabel {{color: rgb({r}, {g}, {b})}}') + r, g, b = self.text_color['fb'] + self.fb_label.setStyleSheet(f'QLabel {{color: rgb({r}, {g}, {b})}}') + + def set_target(self, target): + self.target = target + text = f'{target:.{int(self.decimals)}f}' + if self.unit is not None: + text = text + ' ' + self.unit + self.target_label.setText(text) + self._on_target_or_fb_changed() + + def set_feedback(self, fb): + if self._status != Status.MOVING: + self.fb = fb + text = f'{fb:.{int(self.decimals)}f}' + if self.unit is not None: + text = text + ' ' + self.unit + self.fb_label.setText(text) + self._on_target_or_fb_changed() + + # ------------------------------------------------------------------ + # Button style helpers + # ------------------------------------------------------------------ + def _apply_button_style(self, mode: str): + if mode == "start": + self.btn_action.setText("▶ Move") + self.btn_action.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().success.name()}; color: white;}}" + ) + else: # stop + self.btn_action.setText("■ Stop") + self.btn_action.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().emergency.name()}; color: white;}}" + ) + + # ------------------------------------------------------------------ + # Status management + # ------------------------------------------------------------------ + def _set_status(self, status: str): + self._status = status + self.status_icon.set_status(status) + + # ------------------------------------------------------------------ + # Motion control + # ------------------------------------------------------------------ + def _on_target_or_fb_changed(self): + """Re-evaluate in-position status whenever the target value changes.""" + if self._status in (Status.ERROR, Status.MOVING): + return + if abs(self.fb - self.target) <= self.DEADBAND: + self._set_status(Status.IN_POSITION) + else: + self._set_status(Status.NOT_IN_POSITION) + + def _on_button_clicked(self): + if self._thread and self._thread.isRunning(): + self._stop_motion() + else: + self._start_motion() + + def _start_motion(self): + target = self.target + if abs(target - self.fb) <= self.DEADBAND: + self._set_status(Status.IN_POSITION) + return + + self._set_status(Status.MOVING) + self._apply_button_style("stop") + + self._worker = MotionWorker(self.motor, target) + self._thread = QThread() + self._worker.moveToThread(self._thread) + + # Wire signals + self._thread.started.connect(self._worker.run) + self._worker.position_changed.connect(self._on_position_changed) + self._worker.error.connect(self._on_error) + self._worker.error.connect(self._thread.quit) + self._worker.finished.connect(self._on_motion_finished) + self._worker.finished.connect(self._thread.quit) + self._thread.finished.connect(self._cleanup_thread) + + self._thread.start() + + def _on_error(self): + self._set_status(Status.ERROR) + self._apply_button_style("start") + + def _stop_motion(self): + if self._worker: + self._worker.stop() + # UI will update via finished signal + + def _on_position_changed(self, pos: float): + self.fb = pos + text = f'{pos:.{int(self.decimals)}f}' + if self.unit is not None: + text = text + ' ' + self.unit + self.fb_label.setText(text) + + def _on_motion_finished(self, reached: bool): + target = self.target + if abs(self.fb - target) <= self.DEADBAND: + self._set_status(Status.IN_POSITION) + else: + self._set_status(Status.NOT_IN_POSITION) + self._apply_button_style("start") + + def _cleanup_thread(self): + if self._thread: + self._thread.deleteLater() + self._thread = None + if self._worker: + self._worker.deleteLater() + self._worker = None + + # ------------------------------------------------------------------ + # Called on application close — stop motion safely + # ------------------------------------------------------------------ + def shutdown(self): + if self._worker: + self._worker.stop() + if self._thread: + self._thread.quit() + self._thread.wait(2000) # max 2 s grace period + + +# # --------------------------------------------------------------------------- +# # Main window +# # --------------------------------------------------------------------------- +# class MainWindow(QMainWindow): +# def __init__(self): +# super().__init__() +# self.setWindowTitle("Motor Stage Controller") +# self.setMinimumWidth(620) + +# central = QWidget() +# self.setCentralWidget(central) +# layout = QVBoxLayout(central) +# layout.setContentsMargins(16, 16, 16, 16) +# layout.setSpacing(12) + +# # Title +# title = QLabel("Motor Stage Controller") +# layout.addWidget(title) + +# sep = QFrame() +# sep.setFrameShape(QFrame.HLine) +# layout.addWidget(sep) + +# # Three example stage groups +# self.stages: list[StageGroupWidget] = [] +# for axis in ("X Axis", "Y Axis", "Z Axis"): +# stage = StageGroupWidget(axis) +# self.stages.append(stage) +# layout.addWidget(stage) + +# # Set different initial positions for demo variety +# self.stages[0].spin_value.setValue(25.0) +# self.stages[1].spin_value.setValue(-10.5) +# self.stages[2].spin_value.setValue(5.75) + + +# def closeEvent(self, event): +# """Stop all motion threads before closing.""" +# for stage in self.stages: +# stage.shutdown() +# event.accept() + +# if __name__ == "__main__": +# app = QApplication(sys.argv) +# # app.setStyle("Fusion") +# window = MainWindow() +# window.show() +# sys.exit(app.exec()) diff --git a/debye_bec/bec_widgets/widgets/qt_widgets.py b/debye_bec/bec_widgets/widgets/qt_widgets.py index c5f2057..636b4fe 100644 --- a/debye_bec/bec_widgets/widgets/qt_widgets.py +++ b/debye_bec/bec_widgets/widgets/qt_widgets.py @@ -15,32 +15,6 @@ class Group(QGroupBox): for widget in widgets: self.layout.addWidget(widget) # type: ignore -# class TextIndicator(QWidget): -# def __init__(self, label, unit=None, highlight=False): -# super().__init__() -# layout = QHBoxLayout(self) -# layout.setContentsMargins(10, 0, 0, 0) -# layout.setSpacing(0) -# self.label = QLabel(label) -# self.label.setFixedWidth(150) -# layout.addWidget(self.label) -# self.value = QLabel('-') -# self.value.setFixedWidth(160) -# layout.addWidget(self.value) -# self.unit = unit -# self.highlight = highlight -# if highlight: -# font = QFont() -# font.setBold(True) -# font.setPointSize(14) -# self.label.setFont(font) -# self.value.setFont(font) - -# def set_text(self, text): -# if self.unit is not None: -# text = text + ' ' + self.unit -# self.value.setText(text) - class NumberIndicator(QWidget): def __init__(self, label='', unit=None, highlight=False, decimals=3): super().__init__() @@ -80,38 +54,6 @@ class NumberIndicator(QWidget): text = text + ' ' + self.unit self.val.setText(text) -# class InputTextField(QWidget): -# def __init__(self, topic, label): -# super().__init__() -# self.topic = topic -# layout = QHBoxLayout(self) -# layout.setContentsMargins(10, 0, 0, 0) -# layout.setSpacing(0) -# self.label = QLabel(label) -# self.label.setFixedWidth(140) -# self.label.setContentsMargins(0, 0, 10, 0) -# self.label.setWordWrap(True) -# layout.addWidget(self.label) -# self.val = QLineEdit() -# self.val.setPlaceholderText('0') -# # self.val.setFixedWidth(140) -# layout.addWidget(self.val) - -# def set_text(self, text): -# self.val.setText(text) - -# def has_focus(self) -> bool: -# return self.val.hasFocus() - -# def text(self) -> str: -# return self.val.text() - -# def set_on_return(self, func): -# """Connect a function to the Enter/Return key press.""" -# self.val.returnPressed.connect( -# partial(func, self.val, self.topic, lambda: self.val.text()) -# ) - class InputNumberField(QWidget): def __init__(self, identifier='', label='', unit=None, prefix=None, init=0.0, decimals=1, single_step=0.1, ll=-1e6, hl=1e6): super().__init__() @@ -186,70 +128,96 @@ class ComboBox(QWidget): def setDisabled(self, disable): self.value.setDisabled(disable) -class Mover(QWidget): - def __init__(self, dev, egu, prec): - super().__init__() - layout = QHBoxLayout(self) - layout.setContentsMargins(10, 0, 0, 0) - layout.setSpacing(0) - self.position = QLabel('-') - self.position.setFixedWidth(150) - layout.addWidget(self.position) - self.led = QLabel() - self.led.setFixedWidth(30) - self.led.setStyleSheet("background-color: 0, 0, 0; border: 1px solid black;") - layout.addWidget(self.led) - self.start = QPushButton('Move') - self.start.setStyleSheet("color: black; background-color: green;") - self.start.setFixedWidth(80) - self.stop = QPushButton('Stop') - self.stop.setStyleSheet("color: black; background-color: firebrick;") - self.stop.setFixedWidth(80) - layout.addWidget(self.start) - layout.addWidget(self.stop) - self.dev = dev - self.unit = egu - self.decimals = prec +# class Mover(QWidget): +# def __init__(self, dev, egu, prec): +# super().__init__() +# layout = QHBoxLayout(self) +# layout.setContentsMargins(10, 0, 0, 0) +# layout.setSpacing(0) +# self.position = QLabel('-') +# self.position.setFixedWidth(150) +# layout.addWidget(self.position) +# self.led = QLabel() +# self.led.setFixedWidth(30) +# self.led.setStyleSheet("background-color: 0, 0, 0; border: 1px solid black;") +# layout.addWidget(self.led) +# self.start = QPushButton('Move') +# self.start.setStyleSheet("color: black; background-color: green;") +# self.start.setFixedWidth(80) +# self.stop = QPushButton('Stop') +# self.stop.setStyleSheet("color: black; background-color: firebrick;") +# self.stop.setFixedWidth(80) +# layout.addWidget(self.start) +# layout.addWidget(self.stop) +# self.dev = dev +# self.unit = egu +# self.decimals = prec - def led_set_status(self, status): - if status in 'out': - self.led.setStyleSheet("background-color: 255, 0, 0; border: 1px solid black;") - elif status in 'moving': - self.led.setStyleSheet("background-color: 255, 255, 0; border: 1px solid black;") - elif status in 'in': - self.led.setStyleSheet("background-color: 0, 255, 0; border: 1px solid black;") +# def led_set_status(self, status): +# if status in 'out': +# self.led.setStyleSheet("background-color: 255, 0, 0; border: 1px solid black;") +# elif status in 'moving': +# self.led.setStyleSheet("background-color: 255, 255, 0; border: 1px solid black;") +# elif status in 'in': +# self.led.setStyleSheet("background-color: 0, 255, 0; border: 1px solid black;") - def position_setValue(self, number): - text = f'{number:.{int(self.decimals)}f}' - if self.unit is not None: - text = text + ' ' + self.unit - self.position.setText(text) +# def position_setValue(self, number): +# text = f'{number:.{int(self.decimals)}f}' +# if self.unit is not None: +# text = text + ' ' + self.unit +# self.position.setText(text) - def start_clicked_connect(self, func): - """Connect a function to the start button press.""" - self.start.clicked.connect( - partial(func, dev=self.dev) - ) +# def start_clicked_connect(self, func): +# """Connect a function to the start button press.""" +# self.start.clicked.connect( +# partial(func, dev=self.dev) +# ) - def stop_clicked_connect(self, func): - """Connect a function to the stop button press.""" - self.stop.clicked.connect( - partial(func, dev=self.dev) - ) +# def stop_clicked_connect(self, func): +# """Connect a function to the stop button press.""" +# self.stop.clicked.connect( +# partial(func, dev=self.dev) +# ) - def start_setEnabled(self, enable): - self.start.setEnabled(enable) - if enable: - self.start.setStyleSheet("color: black; background-color: green;") - else: - self.start.setStyleSheet("color: black; background-color: grey;") +# def start_setEnabled(self, enable): +# self.start.setEnabled(enable) +# if enable: +# self.start.setStyleSheet("color: black; background-color: green;") +# else: +# self.start.setStyleSheet("color: black; background-color: grey;") - def stop_setEnabled(self, enable): - self.stop.setEnabled(enable) - if enable: - self.stop.setStyleSheet("color: black; background-color: firebrick;") - else: - self.stop.setStyleSheet("color: black; background-color: grey;") +# def stop_setEnabled(self, enable): +# self.stop.setEnabled(enable) +# if enable: +# self.stop.setStyleSheet("color: black; background-color: firebrick;") +# else: +# self.stop.setStyleSheet("color: black; background-color: grey;") + +# class TextIndicator(QWidget): +# def __init__(self, label, unit=None, highlight=False): +# super().__init__() +# layout = QHBoxLayout(self) +# layout.setContentsMargins(10, 0, 0, 0) +# layout.setSpacing(0) +# self.label = QLabel(label) +# self.label.setFixedWidth(150) +# layout.addWidget(self.label) +# self.value = QLabel('-') +# self.value.setFixedWidth(160) +# layout.addWidget(self.value) +# self.unit = unit +# self.highlight = highlight +# if highlight: +# font = QFont() +# font.setBold(True) +# font.setPointSize(14) +# self.label.setFont(font) +# self.value.setFont(font) + +# def set_text(self, text): +# if self.unit is not None: +# text = text + ' ' + self.unit +# self.value.setText(text) # class Button(QWidget): # def __init__(self, label, label_button): @@ -297,4 +265,36 @@ class Mover(QWidget): # def apply_color(self, val): # color = self.colors[self.states.index(val)] -# self.led.setStyleSheet(f"background-color: {color}; border: 1px solid black;") \ No newline at end of file +# self.led.setStyleSheet(f"background-color: {color}; border: 1px solid black;") + +# class InputTextField(QWidget): +# def __init__(self, topic, label): +# super().__init__() +# self.topic = topic +# layout = QHBoxLayout(self) +# layout.setContentsMargins(10, 0, 0, 0) +# layout.setSpacing(0) +# self.label = QLabel(label) +# self.label.setFixedWidth(140) +# self.label.setContentsMargins(0, 0, 10, 0) +# self.label.setWordWrap(True) +# layout.addWidget(self.label) +# self.val = QLineEdit() +# self.val.setPlaceholderText('0') +# # self.val.setFixedWidth(140) +# layout.addWidget(self.val) + +# def set_text(self, text): +# self.val.setText(text) + +# def has_focus(self) -> bool: +# return self.val.hasFocus() + +# def text(self) -> str: +# return self.val.text() + +# def set_on_return(self, func): +# """Connect a function to the Enter/Return key press.""" +# self.val.returnPressed.connect( +# partial(func, self.val, self.topic, lambda: self.val.text()) +# ) From fe43dafac8739fd5a4c45ae10db408e57a15fc9d Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 7 May 2026 15:49:13 +0200 Subject: [PATCH 45/54] wip: digital twin --- .../widgets/digital_twin/digital_twin.py | 54 +++---- .../widgets/digital_twin/move_widget.py | 137 +----------------- 2 files changed, 32 insertions(+), 159 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 5c1ab1e..dea2df5 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -661,11 +661,11 @@ class MoverPanel(QWidget): # FE Slits mot = self.dev.sldi_gapx - self.sldi_gapx = MoveWidget(motor=mot, label='GAPX', unit='mm', decimals=2) + self.sldi_gapx = MoveWidget(motor=mot, label='GAPX', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.sldi_gapx) mot = self.dev.sldi_gapy - self.sldi_gapy = MoveWidget(motor=mot, label='GAPY', unit='mm', decimals=2) + self.sldi_gapy = MoveWidget(motor=mot, label='GAPY', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.sldi_gapy) self.sldi_mov_group = Group( @@ -678,19 +678,19 @@ class MoverPanel(QWidget): # Collimating mirror mot = self.dev.cm_trx - self.cm_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2) + self.cm_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.cm_trx) mot = self.dev.cm_try - self.cm_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2) + self.cm_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.cm_try) mot = self.dev.cm_bnd - self.cm_bnd = MoveWidget(motor=mot, label='BENDER', unit='km', decimals=2) + self.cm_bnd = MoveWidget(motor=mot, label='BENDER', unit='km', decimals=2, deadband=0.2) self.mover_widgets.append(self.cm_bnd) mot = self.dev.cm_rotx - self.cm_rotx = MoveWidget(motor=mot, label='PITCH', unit='mrad', decimals=3) + self.cm_rotx = MoveWidget(motor=mot, label='PITCH', unit='mrad', decimals=3, deadband=0.01) self.mover_widgets.append(self.cm_rotx) self.cm_mov_group = Group( @@ -705,15 +705,15 @@ class MoverPanel(QWidget): # Monochromator mot = self.dev.mo1_bragg - self.mo1_bragg_angle = MoveWidget(motor=mot, label='Bragg Angle', unit='deg', decimals=3) + self.mo1_bragg_angle = MoveWidget(motor=mot, label='Bragg Angle', unit='deg', decimals=3, deadband=0.01) self.mover_widgets.append(self.mo1_bragg_angle) mot = self.dev.mo1_trx - self.mo1_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2) + self.mo1_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.mo1_trx) mot = self.dev.mo1_try - self.mo1_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2) + self.mo1_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.mo1_try) self.mo1_mov_group = Group( @@ -727,7 +727,7 @@ class MoverPanel(QWidget): # OP Slits 1 mot = self.dev.sl1_centery - self.sl1_centery = MoveWidget(motor=mot, label='CENTERY', unit='mm', decimals=2) + self.sl1_centery = MoveWidget(motor=mot, label='CENTERY', unit='mm', decimals=2, deadband=0.1) self.mover_widgets.append(self.sl1_centery) self.sl1_mov_group = Group( @@ -739,7 +739,7 @@ class MoverPanel(QWidget): # OP Beam Monitor 1 mot = self.dev.bm1_try - self.bm1_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2) + self.bm1_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2, deadband=0.1) self.mover_widgets.append(self.bm1_try) self.bm1_mov_group = Group( @@ -751,19 +751,19 @@ class MoverPanel(QWidget): # Focusing Mirror mot = self.dev.fm_trx - self.fm_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2) + self.fm_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.fm_trx) mot = self.dev.fm_try - self.fm_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2) + self.fm_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.fm_try) mot = self.dev.fm_bnd - self.fm_bnd = MoveWidget(motor=mot, label='BENDER', unit='km', decimals=2) + self.fm_bnd = MoveWidget(motor=mot, label='BENDER', unit='km', decimals=2, deadband=0.2) self.mover_widgets.append(self.fm_bnd) mot = self.dev.fm_rotx - self.fm_rotx = MoveWidget(motor=mot, label='PITCH', unit='mrad', decimals=3) + self.fm_rotx = MoveWidget(motor=mot, label='PITCH', unit='mrad', decimals=3, deadband=0.01) self.mover_widgets.append(self.fm_rotx) self.fm_mov_group = Group( @@ -778,7 +778,7 @@ class MoverPanel(QWidget): # OP Slits 2 mot = self.dev.sl2_centery - self.sl2_centery = MoveWidget(motor=mot, label='CENTERY', unit='mm', decimals=2) + self.sl2_centery = MoveWidget(motor=mot, label='CENTERY', unit='mm', decimals=2, deadband=0.1) self.mover_widgets.append(self.sl2_centery) self.sl2_mov_group = Group( @@ -790,7 +790,7 @@ class MoverPanel(QWidget): # OP Beam Monitor 2 mot = self.dev.bm2_try - self.bm2_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2) + self.bm2_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2, deadband=0.1) self.mover_widgets.append(self.bm2_try) self.bm2_mov_group = Group( @@ -802,15 +802,15 @@ class MoverPanel(QWidget): # Optical Table mot = self.dev.ot_try - self.ot_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2) + self.ot_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2, deadband=0.2) self.mover_widgets.append(self.ot_try) mot = self.dev.ot_rotx - self.ot_rotx = MoveWidget(motor=mot, label='ROTX', unit='mrad', decimals=3) + self.ot_rotx = MoveWidget(motor=mot, label='ROTX', unit='mrad', decimals=3, deadband=0.05) self.mover_widgets.append(self.ot_rotx) mot = self.dev.ot_es1_trz - self.ot_es1_trz = MoveWidget(motor=mot, label='ES1 TRZ', unit='mm', decimals=0) + self.ot_es1_trz = MoveWidget(motor=mot, label='ES1 TRZ', unit='mm', decimals=0, deadband=5) self.mover_widgets.append(self.ot_es1_trz) self.ot_mov_group = Group( @@ -950,7 +950,7 @@ class SurfacePlots(QWidget): self.colors = [(79, 163, 224), (240, 128, 60)] self.text_color = (255, 255, 255) else: # dark theme - self.color_impenetrable = (220, 220, 220) + self.color_impenetrable = (180, 180, 180) self.colors = [(26, 111, 173), (212, 83, 10)] self.text_color = (0, 0, 0) @@ -1091,26 +1091,26 @@ class SideviewPlot(QWidget): self.colors = [(79, 163, 224), (240, 128, 60)] self.text_color = (255, 255, 255) else: # dark theme - self.color_impenetrable = (220, 220, 220) + self.color_impenetrable = (180, 180, 180) self.colors = [(26, 111, 173), (212, 83, 10)] self.text_color = (0, 0, 0) for idx, scene in enumerate(self.data): if scene in 'assistant': brush = QBrush(QColor(*self.colors[idx], 255), Qt.DiagCrossPattern) - pen = pg.mkPen(QColor(*self.colors[idx], 255), width=1, style=Qt.DashLine) + pen = pg.mkPen(QColor(*self.colors[idx], 255), width=3, style=Qt.DashLine) else: brush = QBrush(QColor(*self.colors[idx], 255)) - pen = pg.mkPen(QColor(*self.colors[idx], 255), width=1) + pen = pg.mkPen(QColor(*self.colors[idx], 255), width=3) self.plots[scene].setPen(pen) self.plots[scene].setBrush(brush) for wall in self.walls: - wall.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) + wall.setPen(pg.mkPen(color=self.color_impenetrable, width=3)) wall.setBrush(pg.QtGui.QBrush(pg.QtGui.QColor(*self.color_impenetrable))) # pylint: disable=E1101 for pipe in self.pipes: - pipe.setPen(pg.mkPen(color=self.color_impenetrable, width=2)) + pipe.setPen(pg.mkPen(color=self.color_impenetrable, width=3)) def plot_vacuum_pipes(self): pipes = pipe_geometries() @@ -1141,7 +1141,7 @@ if __name__ == "__main__": from bec_widgets.utils.colors import apply_theme app = QApplication(sys.argv) - apply_theme("light") + apply_theme("dark") dispatcher = BECDispatcher(gui_id="digital_twin") win = DigitalTwin() win.show() diff --git a/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py b/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py index 2067042..b3b9a40 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py @@ -22,47 +22,6 @@ class Status: MOVING = "moving" # blue mdi.loading (spinning) ERROR = "error" # red mdi.alert-circle -# class StatusIcon(qta.IconWidget): -# """ -# Displays a status icon using qtawesome Material Design Icons. -# Handles its own spin animation for the MOVING state via qta.Spin. -# """ - -# ICON_SIZE = 36 - -# # Map each status to an (icon_name, color) pair -# _ICON_MAP = { -# Status.IN_POSITION: ("mdi.check-circle-outline", "#27ae60"), -# Status.NOT_IN_POSITION: ("mdi.close-circle-outline", "#e6d922"), -# Status.ERROR: ("mdi.alert-outline", "#e74c3c"), -# Status.MOVING: ("mdi.gamepad-circle-outline", "#2980b9"), -# } - -# def __init__(self, parent=None): -# super().__init__(parent=parent) -# self._status = None -# self._spin_anim = qta.Spin(self, autostart=True) -# self.setFixedSize(self.ICON_SIZE, self.ICON_SIZE) -# self.set_status(Status.NOT_IN_POSITION) - -# def set_status(self, status: str): -# if status == self._status: -# return -# self._status = status - -# icon_name, color = self._ICON_MAP[status] - -# if status == Status.MOVING: -# icon = qta.icon(icon_name, color=color, animation=self._spin_anim) -# self._spin_anim.start() -# else: -# self._spin_anim.stop() -# icon = qta.icon(icon_name, color=color) - -# self.setIcon(icon) -# self.setIconSize(QSize(self.ICON_SIZE, self.ICON_SIZE)) - - class StatusIcon(QWidget): """ Displays a status icon using bec_qthemes Material Design Icons. @@ -160,41 +119,6 @@ class MotionWorker(QObject): # time.sleep(0.1) self.finished.emit(True) - # def run(self): - # """Simulate motion: move in small steps with realistic deceleration.""" - # distance = abs(self._target - self._current) - # direction = 1 if self._target > self._current else -1 - # speed = max(0.5, distance / 3.0) # units/second (simulated) - # step_time = 0.05 # seconds per step - - # pos = self._current - # while not self._stop_flag.is_set(): - # remaining = abs(self._target - pos) - # if remaining < 0.01: - # pos = self._target - # self.position_changed.emit(round(pos, 4)) - # self.finished.emit(True) - # return - - # # Decelerate near the end - # effective_speed = speed * min(1.0, remaining / (distance * 0.2 + 0.001)) - # effective_speed = max(0.05, effective_speed) - # step = effective_speed * step_time * direction - # if abs(step) > remaining: - # step = remaining * direction - - # pos += step - # pos += random.gauss(0, 0.002) # tiny simulated encoder noise - # if pos > 20: # Simulated error if above 20 mm - # self.error.emit(True) - # return - # self.position_changed.emit(round(pos, 4)) - # time.sleep(step_time) - - # # Stopped by user - # self.position_changed.emit(round(pos, 4)) - # self.finished.emit(False) - class MoveWidget(QWidget): """ One motor stage control group containing: @@ -204,13 +128,12 @@ class MoveWidget(QWidget): - Start / Stop button """ - DEADBAND = 0.02 # mm — positions within this tolerance are "in position" - - def __init__(self, motor, label: str = '', unit=None, decimals=3): + def __init__(self, motor, label: str = '', unit=None, decimals=3, deadband=0.0): super().__init__() self.fb = 0.0 self.target = 0 self.motor = motor + self.deadband = deadband self._status = Status.IN_POSITION self._thread: QThread | None = None self._worker: MotionWorker | None = None @@ -320,7 +243,7 @@ class MoveWidget(QWidget): """Re-evaluate in-position status whenever the target value changes.""" if self._status in (Status.ERROR, Status.MOVING): return - if abs(self.fb - self.target) <= self.DEADBAND: + if abs(self.fb - self.target) <= self.deadband: self._set_status(Status.IN_POSITION) else: self._set_status(Status.NOT_IN_POSITION) @@ -333,7 +256,7 @@ class MoveWidget(QWidget): def _start_motion(self): target = self.target - if abs(target - self.fb) <= self.DEADBAND: + if abs(target - self.fb) <= self.deadband: self._set_status(Status.IN_POSITION) return @@ -373,7 +296,7 @@ class MoveWidget(QWidget): def _on_motion_finished(self, reached: bool): target = self.target - if abs(self.fb - target) <= self.DEADBAND: + if abs(self.fb - target) <= self.deadband: self._set_status(Status.IN_POSITION) else: self._set_status(Status.NOT_IN_POSITION) @@ -396,53 +319,3 @@ class MoveWidget(QWidget): if self._thread: self._thread.quit() self._thread.wait(2000) # max 2 s grace period - - -# # --------------------------------------------------------------------------- -# # Main window -# # --------------------------------------------------------------------------- -# class MainWindow(QMainWindow): -# def __init__(self): -# super().__init__() -# self.setWindowTitle("Motor Stage Controller") -# self.setMinimumWidth(620) - -# central = QWidget() -# self.setCentralWidget(central) -# layout = QVBoxLayout(central) -# layout.setContentsMargins(16, 16, 16, 16) -# layout.setSpacing(12) - -# # Title -# title = QLabel("Motor Stage Controller") -# layout.addWidget(title) - -# sep = QFrame() -# sep.setFrameShape(QFrame.HLine) -# layout.addWidget(sep) - -# # Three example stage groups -# self.stages: list[StageGroupWidget] = [] -# for axis in ("X Axis", "Y Axis", "Z Axis"): -# stage = StageGroupWidget(axis) -# self.stages.append(stage) -# layout.addWidget(stage) - -# # Set different initial positions for demo variety -# self.stages[0].spin_value.setValue(25.0) -# self.stages[1].spin_value.setValue(-10.5) -# self.stages[2].spin_value.setValue(5.75) - - -# def closeEvent(self, event): -# """Stop all motion threads before closing.""" -# for stage in self.stages: -# stage.shutdown() -# event.accept() - -# if __name__ == "__main__": -# app = QApplication(sys.argv) -# # app.setStyle("Fusion") -# window = MainWindow() -# window.show() -# sys.exit(app.exec()) From 3e959e6c5ddbb5fab051db9547a5bc1f62bf9897 Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 11 May 2026 10:16:42 +0200 Subject: [PATCH 46/54] wip: digital twin --- .../widgets/digital_twin/calc_positions.py | 40 ++- .../widgets/digital_twin/calc_surfaces.py | 9 +- .../widgets/digital_twin/calc_varia.py | 8 +- .../widgets/digital_twin/digital_twin.py | 218 +++++++++++----- .../widgets/digital_twin/move_widget.py | 243 +++++++++++++++--- debye_bec/bec_widgets/widgets/qt_widgets.py | 94 +++---- .../device_configs/x01da_standard_config.yaml | 18 +- debye_bec/devices/absorber.py | 4 +- 8 files changed, 452 insertions(+), 182 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calc_positions.py b/debye_bec/bec_widgets/widgets/digital_twin/calc_positions.py index 971b7e1..569145e 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calc_positions.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calc_positions.py @@ -17,19 +17,20 @@ def calc_positions(cfg): tryb = -np.arctan(cfg['v_acc'])*bl.feSlits.center1[1] tryt = (np.arctan(cfg['v_acc'])*bl.feSlits.center1[1])/bl.feSlits.center1[1]*bl.feSlits.center2[1] - trxw_proj = trxw/bl.feSlits.center2[1]*bl.feSlits.center1[1] - tryt_proj = tryt/bl.feSlits.center2[1]*bl.feSlits.center1[1] + # trxw_proj = trxw/bl.feSlits.center2[1]*bl.feSlits.center1[1] + # tryt_proj = tryt/bl.feSlits.center2[1]*bl.feSlits.center1[1] - xcen = (trxr + trxw_proj) / 2 - ycen = (tryb + tryt_proj) / 2 - xgap = trxw_proj - trxr - ygap = tryt_proj - tryb + # xcen = (trxr + trxw) / 2 + # ycen = (tryb + tryt) / 2 + xgap = trxw - trxr + ygap = tryt - tryb pos['sldi_gapx'] = {'value': xgap} pos['sldi_gapy'] = {'value': ygap} ## Collimating Mirror obj_dist = bl.cm.center[1] # object distance + beam_vs = 2 * obj_dist * np.tan(cfg['v_acc']) # vertical size of beam after CM # TRX try: @@ -52,17 +53,23 @@ def calc_positions(cfg): ## Monochromator # Bragg Angle - # TODO Should the bragg angle be corrected for the symmetric bragg case? - # See raytracing script or here: bragg = np.asin(rm.ch / (2.*cfg['dSpacing']*cfg['energyCCM'])) - aCrystal.get_dtheta_symmetric_Bragg(cfg['energyCCM']) + # if cfg['mo1_mode'] == 'Monochromatic': + # # Add 2x CM pitch to the bragg angle + # bragg = ((2 * cfg['cm_pitch']) + cfg['mo1_bragg']) / np.pi * 180 + # elif cfg['mo1_mode'] == 'Pinkbeam': + # # Align xtal surfaces parallel to beam + # bragg = (2 * cfg['cm_pitch']) / np.pi * 180 + # else: + # raise Exception('Monochromator mode not supported') if cfg['mo1_mode'] == 'Monochromatic': # Add 2x CM pitch to the bragg angle - bragg = ((2 * cfg['cm_pitch']) + cfg['mo1_bragg']) / np.pi * 180 + bragg = cfg['mo1_bragg'] elif cfg['mo1_mode'] == 'Pinkbeam': # Align xtal surfaces parallel to beam - bragg = (2 * cfg['cm_pitch']) / np.pi * 180 + bragg = 0 else: raise Exception('Monochromator mode not supported') - pos['mo1_bragg_angle'] = {'value': bragg} # Bragg angle in deg + pos['mo1_bragg_angle'] = {'value': bragg/np.pi*180} # Bragg angle in deg # TRY, Height l = bl.mo1.xtalGap[0]/np.sin(cfg['mo1_bragg']) @@ -124,13 +131,14 @@ def calc_positions(cfg): #TODO move to mono, calc for beam Z-movement between crystal surfaces - diag = bl.mo1.xtalGap[0] / np.sin(bragg) # Calculations for Mono - dz = diag * np.cos(2 * (cfg['cm_pitch'] + bragg)) + diag = bl.mo1.xtalGap[0] / np.sin(cfg['mo1_bragg']) # Calculations for Mono + dz = diag * np.cos(2 * (cfg['cm_pitch'] + cfg['mo1_bragg'])) ## Slits 1 d = bl.opSlits1.center[1] - bl.cm.center[1] - dz sl1_beam_height = d * np.tan(2 * cfg['cm_pitch']) + beamOffsetCCM pos['sl1_centery'] = {'value': sl1_beam_height} + pos['sl1_gapy'] = {'value': beam_vs + 1} # Add 0.5 mm space on both sides of the beam ## Beam Monitor 1 d = bl.opBM1.center[1] - bl.cm.center[1] - dz @@ -201,13 +209,14 @@ def calc_positions(cfg): d = bl.opSlits2.center[1] - bl.fm.center[1] sl2_beam_height = fm_beam_height - d * np.tan(-(2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch'])) pos['sl2_centery'] = {'value': sl2_beam_height} + pos['sl2_gapy'] = {'value': beam_vs + 1} # Add 0.5 mm space on both sides of the beam ## Beam Monitor 2 d = bl.opBM2.center[1] - bl.fm.center[1] bm2_beam_height = fm_beam_height - d * np.tan(-(2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch'])) pos['bm2_try'] = {'value': bm2_beam_height} - ## Optical Table / Exit Window + ## Optical Table # TRY d = bl.ehWindow.center[1] - bl.fm.center[1] @@ -224,4 +233,7 @@ def calc_positions(cfg): ot_es1_trz = cfg['smpl'] pos['ot_es1_trz'] = {'value': ot_es1_trz} + # ES0 exit window + pos['es0wi_try'] = {'value': 5} # At 5mm, the middle of the window is 500 mm from the table (neutral position) + return pos diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py b/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py index bdaf703..7162d45 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py @@ -3,6 +3,8 @@ import re import numpy as np from bec_lib import bec_logger +logger = bec_logger.logger + os.environ["USE_XRT"] = "False" import debye_bec.bec_widgets.widgets.x01da_parameters as bl @@ -60,7 +62,6 @@ def calc_surfaces(cfg): out['mo1_2']['y'] = [] # Focusing mirror - if cfg['fm_stripe'] in ('Rh (toroid)', 'Pt (toroid)'): surface = bl.fm.surfaceToroid stripe = re.sub(r'\s*\(.*?\)', '', cfg['fm_stripe']).strip() @@ -88,6 +89,12 @@ def calc_surfaces(cfg): x = [off-widthBeam/2, off-widthBeam/2] y = [l/2-z/2, -l/2-z/2] + # logger.info(f'stripe: {cfg["fm_stripe"]}') + # logger.info(f'fm_pitch: {cfg["fm_pitch"]}') + # logger.info(f'h: {h}') + # logger.info(f'z: {z}') + # logger.info(f'r: {r}') + res = 20 xElipse = np.linspace(0, np.pi, res) yElipse = np.linspace(0, np.pi, res) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calc_varia.py b/debye_bec/bec_widgets/widgets/digital_twin/calc_varia.py index 11c1c42..029be5d 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calc_varia.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calc_varia.py @@ -10,8 +10,12 @@ logger = bec_logger.logger def sldi_gap_to_acc(sldi_gapx, sldi_gapy): d1 = bl.feSlits.center1[1] - h_acc = np.tan(sldi_gapx / (2 * d1)) - v_acc = np.tan(sldi_gapy / (2 * d1)) + d2 = bl.feSlits.center2[1] + h_acc = np.tan(sldi_gapx / (d2 + d1)) + v_acc = np.tan(sldi_gapy / (d2 + d1)) + + # h_acc = np.tan(sldi_gapx / (2 * d1)) + # v_acc = np.tan(sldi_gapy / (2 * d1)) return h_acc, v_acc def cm_trx_to_stripe(cm_trx): diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index dea2df5..6337ca1 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -35,8 +35,9 @@ from debye_bec.bec_widgets.widgets.qt_widgets import ( ComboBox, Group, NumberIndicator, + Button, ) -from debye_bec.bec_widgets.widgets.digital_twin.move_widget import MoveWidget +from debye_bec.bec_widgets.widgets.digital_twin.move_widget import MoveWidget, AbsorberWidget from debye_bec.bec_widgets.widgets.digital_twin.calc_positions import calc_positions from debye_bec.bec_widgets.widgets.digital_twin.calc_sideview import calc_sideview from debye_bec.bec_widgets.widgets.digital_twin.calc_surfaces import calc_surfaces @@ -55,6 +56,7 @@ from debye_bec.bec_widgets.widgets.digital_twin.calc_varia import ( wall_geometries, pipe_geometries, ) +from debye_bec.devices.absorber import STATUS as ABS_STATUS logger = bec_logger.logger @@ -106,6 +108,8 @@ class DigitalTwin(BECWidget, QWidget): self.input.fm_focy.value_changed_connect(self.calc_assistant) self.input.smpl.value_changed_connect(self.calc_assistant) + self.input.adapt_reality.clicked_connect(self.adapt_reality) + self.bragg_angle = 0 self.qy = 0 @@ -128,6 +132,7 @@ class DigitalTwin(BECWidget, QWidget): identifier = kwargs['identifier'] match identifier: case 'init': + self.update_mo1_mode() self.calc_mo1_bragg_angle() self.calc_cm_crit_pitch() self.calc_cm_reflectivity() @@ -215,11 +220,11 @@ class DigitalTwin(BECWidget, QWidget): sldi_gapx = self.dev.sldi_gapx.read(cached=True)['sldi_gapx']['value'] sldi_gapy = self.dev.sldi_gapy.read(cached=True)['sldi_gapy']['value'] h_acc, v_acc = sldi_gap_to_acc(sldi_gapx, sldi_gapy) - cm_trx = -self.dev.cm_trx.read(cached=True)['cm_trx']['value'] - cm_stripe = cm_trx_to_stripe(cm_trx) + cm_trx = self.dev.cm_trx.read(cached=True)['cm_trx']['value'] + cm_stripe = cm_trx_to_stripe(-cm_trx) cm_pitch = self.dev.cm_rotx.read(cached=True)['cm_rotx']['value'] - fm_trx = -self.dev.fm_trx.read(cached=True)['fm_trx']['value'] - fm_stripe = fm_trx_to_stripe(fm_trx) + fm_trx = self.dev.fm_trx.read(cached=True)['fm_trx']['value'] + fm_stripe = fm_trx_to_stripe(-fm_trx) fm_pitch = self.dev.fm_rotx.read(cached=True)['fm_rotx']['value'] fm_pitch_real = 2 * cm_pitch - fm_pitch smpl = self.dev.ot_es1_trz.read(cached=True)['ot_es1_trz']['value'] @@ -229,17 +234,31 @@ class DigitalTwin(BECWidget, QWidget): 'v_acc' : v_acc, 'cm_pitch' : -cm_pitch * 1e-3, 'cm_stripe' : cm_stripe, - 'cm_trx' : cm_trx, + 'cm_trx' : -cm_trx, 'mo1_mode' : mo1_mode, 'mo1_xtal' : mo1_bragg['mo1_bragg_crystal_current_xtal_string']['value'], 'mo1_bragg' : mo1_bragg['mo1_bragg_angle']['value']/180*np.pi, 'fm_pitch' : -fm_pitch_real * 1e-3, 'fm_stripe' : fm_stripe, - 'fm_trx' : fm_trx, + 'fm_trx' : -fm_trx, 'fm_gain_height' : 1, 'smpl' : smpl, } # logger.info(f'Config created: {config}') + + abs_open = self.dev.abs.read(cached=True)['abs_status_string']['value'] == 'OPEN' + if not abs_open: + ready = True + for mover in self.mover.mover_widgets: + if mover.status in ('moving', 'error'): + ready = False + if ready: + self.mover.abs.enable_open(1) # Enable open button + else: + self.mover.abs.enable_open(0) # Disable open button + else: + self.mover.abs.enable_open(0) # Disable open button + self.mover.sldi_gapx.set_feedback(sldi_gapx) self.mover.sldi_gapy.set_feedback(sldi_gapy) self.mover.cm_trx.set_feedback(cm_trx) @@ -250,17 +269,57 @@ class DigitalTwin(BECWidget, QWidget): self.mover.mo1_trx.set_feedback(mo1_trx) self.mover.mo1_try.set_feedback(self.dev.mo1_try.read(cached=True)['mo1_try']['value']) self.mover.sl1_centery.set_feedback(self.dev.sl1_centery.read(cached=True)['sl1_centery']['value']) + self.mover.sl1_gapy.set_feedback(self.dev.sl1_gapy.read(cached=True)['sl1_gapy']['value']) self.mover.bm1_try.set_feedback(self.dev.bm1_try.read(cached=True)['bm1_try']['value']) self.mover.fm_trx.set_feedback(fm_trx) self.mover.fm_try.set_feedback(self.dev.fm_try.read(cached=True)['fm_try']['value']) self.mover.fm_bnd.set_feedback(self.dev.fm_bnd_radius.read(cached=True)['fm_bnd_radius']['value']) self.mover.fm_rotx.set_feedback(fm_pitch) + self.mover.fm_roty.set_feedback(self.dev.fm_roty.read(cached=True)['fm_roty']['value']) + self.mover.fm_rotz.set_feedback(self.dev.fm_rotz.read(cached=True)['fm_rotz']['value']) self.mover.sl2_centery.set_feedback(self.dev.sl2_centery.read(cached=True)['sl2_centery']['value']) + self.mover.sl2_gapy.set_feedback(self.dev.sl2_gapy.read(cached=True)['sl2_gapy']['value']) self.mover.bm2_try.set_feedback(self.dev.bm2_try.read(cached=True)['bm2_try']['value']) self.mover.ot_try.set_feedback(self.dev.ot_try.read(cached=True)['ot_try']['value']) self.mover.ot_rotx.set_feedback(self.dev.ot_rotx.read(cached=True)['ot_rotx']['value']) self.mover.ot_es1_trz.set_feedback(smpl) + self.mover.es0wi_try.set_feedback(self.dev.es0wi_try.read(cached=True)['es0wi_try']['value']) + self.mover.abs.set_feedback(abs_open) return config + + def adapt_reality(self, *args): + self.input.energy.set_number(self.dev.mo1_bragg.read(cached=True)['mo1_bragg']['value']) + h_acc, v_acc = sldi_gap_to_acc( + self.dev.sldi_gapx.read(cached=True)['sldi_gapx']['value'], + self.dev.sldi_gapy.read(cached=True)['sldi_gapy']['value'] + ) + self.input.sldi_hacc.set_number(h_acc*1e3) + self.input.sldi_vacc.set_number(v_acc*1e3) + self.input.cm_stripe.set_current_text( + cm_trx_to_stripe(-self.dev.cm_trx.read(cached=True)['cm_trx']['value']) + ) + cm_pitch = self.dev.cm_rotx.read(cached=True)['cm_rotx']['value'] + self.input.cm_pitch.set_number(cm_pitch) + mo1_trx = self.dev.mo1_trx.read(cached=True)['mo1_trx']['value'] + if abs(mo1_trx) > 5: + mo1_mode = 'Monochromatic' + else: + mo1_mode = 'Pinkbeam' + self.input.mo1_mode.set_current_text(mo1_mode) + self.input.mo1_xtal.set_current_text( + self.dev.mo1_bragg.read(cached=True)['mo1_bragg_crystal_current_xtal_string']['value'] + ) + self.input.fm_stripe.set_current_text( + fm_trx_to_stripe(-self.dev.fm_trx.read(cached=True)['fm_trx']['value']) + ) + self.input.fm_focus.set_current_text('Manual') + fm_pitch = self.dev.fm_rotx.read(cached=True)['fm_rotx']['value'] + fm_pitch_real = 2 * cm_pitch - fm_pitch + self.input.fm_pitch.set_number(fm_pitch_real) + self.input.smpl.set_number( + self.dev.ot_es1_trz.read(cached=True)['ot_es1_trz']['value'] + ) + self.calc_assistant(identifier='init') def update_fm_mode(self): fm_focus = self.input.fm_focus.currentText() @@ -288,6 +347,7 @@ class DigitalTwin(BECWidget, QWidget): beam = calc_sideview(config) data = {'x': beam['x'], 'y': beam['y']} self.sideview_plot.update_curves('reality', data) + # logger.info('Calc reality surfaces') surfaces = calc_surfaces(config) self.surface_plots.update_surfaces(scene='reality', data=surfaces) @@ -329,6 +389,7 @@ class DigitalTwin(BECWidget, QWidget): self.sideview_plot.update_curves('assistant', data) def calc_assistant_surfaces(self): + # logger.info('Calc assistant surfaces') surfaces = calc_surfaces(self.get_assistant_config()) self.surface_plots.update_surfaces(scene='assistant', data=surfaces) @@ -365,16 +426,19 @@ class DigitalTwin(BECWidget, QWidget): self.mover.mo1_trx.set_target(out['mo1_trx']['value']) self.mover.mo1_try.set_target(out['mo1_try']['value']) self.mover.sl1_centery.set_target(out['sl1_centery']['value']) + self.mover.sl1_gapy.set_target(out['sl1_gapy']['value']) self.mover.bm1_try.set_target(out['bm1_try']['value']) self.mover.fm_trx.set_target(out['fm_trx']['value']) self.mover.fm_try.set_target(out['fm_try']['value']) self.mover.fm_bnd.set_target(out['fm_bnd_radius']['value']) self.mover.fm_rotx.set_target(out['fm_rotx']['value']) self.mover.sl2_centery.set_target(out['sl2_centery']['value']) + self.mover.sl2_gapy.set_target(out['sl2_gapy']['value']) self.mover.bm2_try.set_target(out['bm2_try']['value']) self.mover.ot_try.set_target(out['ot_try']['value']) self.mover.ot_rotx.set_target(out['ot_rotx']['value']) self.mover.ot_es1_trz.set_target(out['ot_es1_trz']['value']) + self.mover.es0wi_try.set_target(out['es0wi_try']['value']) def calc_mo1_bragg_angle(self): """ @@ -392,7 +456,7 @@ class DigitalTwin(BECWidget, QWidget): energy = self.input.energy.value() theta, theta_cor = mo1_bragg_angle(mo1_mode, d_spacing, energy, cm_pitch) self.bragg_angle = theta - self.input.mo1_bragg_angle.setValue(theta_cor / np.pi * 180) + self.input.mo1_bragg_angle.setValue(theta / np.pi * 180) def update_mo1_mode(self): if self.input.mo1_mode.currentText() in 'Monochromatic': @@ -429,12 +493,15 @@ class InputPanel(QWidget): self._layout = QVBoxLayout(self) self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore + # Adapt to reality + self.adapt_reality = Button(label_button='Adapt to reality', enabled=True) + # Energy self.energy = InputNumberField('energy', 'Energy', unit='eV', init=8979, decimals=0, single_step=100, ll=4000, hl=65000) # FE Slits Acceptance - self.sldi_hacc = InputNumberField('h_acc', 'Horizontal', unit='mrad', prefix='±', init=0.25, decimals=2, single_step=0.01, ll=-0.1, hl=0.9) - self.sldi_vacc = InputNumberField('v_acc', 'Vertical', unit='mrad', prefix='±', init=0.1, decimals=2, single_step=0.01, ll=-0.1, hl=0.5) + self.sldi_hacc = InputNumberField('h_acc', 'Horizontal', unit='mrad', prefix='±', init=0.25, decimals=3, single_step=0.01, ll=-0.1, hl=0.9) + self.sldi_vacc = InputNumberField('v_acc', 'Vertical', unit='mrad', prefix='±', init=0.1, decimals=3, single_step=0.01, ll=-0.1, hl=0.5) self.sldi_ass_group = Group( 'FE Slits Acceptance', [ @@ -506,6 +573,7 @@ class InputPanel(QWidget): self.input_group = Group( 'User Input', [ + self.adapt_reality, self.energy, self.sldi_ass_group, self.cm_ass_group, @@ -657,15 +725,12 @@ class MoverPanel(QWidget): self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore self.mover_widgets = [] - self.dev = dev # FE Slits - mot = self.dev.sldi_gapx - self.sldi_gapx = MoveWidget(motor=mot, label='GAPX', unit='mm', decimals=2, deadband=0.01) + self.sldi_gapx = MoveWidget(dev=dev, motor='sldi_gapx', label='GAPX', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.sldi_gapx) - mot = self.dev.sldi_gapy - self.sldi_gapy = MoveWidget(motor=mot, label='GAPY', unit='mm', decimals=2, deadband=0.01) + self.sldi_gapy = MoveWidget(dev=dev, motor='sldi_gapy', label='GAPY', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.sldi_gapy) self.sldi_mov_group = Group( @@ -676,21 +741,27 @@ class MoverPanel(QWidget): ] ) + # Absorber + self.abs = AbsorberWidget(absorber=dev.abs, label='') + + self.abs_group = Group( + 'Absorber', + [ + self.abs, + ] + ) + # Collimating mirror - mot = self.dev.cm_trx - self.cm_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2, deadband=0.01) + self.cm_trx = MoveWidget(dev=dev, motor='cm_trx', label='TRX', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.cm_trx) - mot = self.dev.cm_try - self.cm_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2, deadband=0.01) + self.cm_try = MoveWidget(dev=dev, motor='cm_try', label='TRY', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.cm_try) - mot = self.dev.cm_bnd - self.cm_bnd = MoveWidget(motor=mot, label='BENDER', unit='km', decimals=2, deadband=0.2) + self.cm_bnd = MoveWidget(dev=dev, motor='cm_bnd', label='BENDER', unit='km', decimals=2, deadband=0.2) self.mover_widgets.append(self.cm_bnd) - mot = self.dev.cm_rotx - self.cm_rotx = MoveWidget(motor=mot, label='PITCH', unit='mrad', decimals=3, deadband=0.01) + self.cm_rotx = MoveWidget(dev=dev, motor='cm_rotx', label='PITCH', unit='mrad', decimals=3, deadband=0.01) self.mover_widgets.append(self.cm_rotx) self.cm_mov_group = Group( @@ -704,16 +775,13 @@ class MoverPanel(QWidget): ) # Monochromator - mot = self.dev.mo1_bragg - self.mo1_bragg_angle = MoveWidget(motor=mot, label='Bragg Angle', unit='deg', decimals=3, deadband=0.01) + self.mo1_bragg_angle = MoveWidget(dev=dev, motor='mo1_bragg_angle', label='Bragg Angle', unit='deg', decimals=3, deadband=0.01) self.mover_widgets.append(self.mo1_bragg_angle) - mot = self.dev.mo1_trx - self.mo1_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2, deadband=0.01) + self.mo1_trx = MoveWidget(dev=dev, motor='mo1_trx', label='TRX', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.mo1_trx) - mot = self.dev.mo1_try - self.mo1_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2, deadband=0.01) + self.mo1_try = MoveWidget(dev=dev, motor='mo1_try', label='TRY', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.mo1_try) self.mo1_mov_group = Group( @@ -726,20 +794,22 @@ class MoverPanel(QWidget): ) # OP Slits 1 - mot = self.dev.sl1_centery - self.sl1_centery = MoveWidget(motor=mot, label='CENTERY', unit='mm', decimals=2, deadband=0.1) + self.sl1_centery = MoveWidget(dev=dev, motor='sl1_centery', label='CENTERY', unit='mm', decimals=2, deadband=0.1) self.mover_widgets.append(self.sl1_centery) + self.sl1_gapy = MoveWidget(dev=dev, motor='sl1_gapy', label='GAPY', unit='mm', decimals=2, deadband=0.1) + self.mover_widgets.append(self.sl1_gapy) + self.sl1_mov_group = Group( 'OP Slits 1', [ self.sl1_centery, + self.sl1_gapy, ] ) # OP Beam Monitor 1 - mot = self.dev.bm1_try - self.bm1_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2, deadband=0.1) + self.bm1_try = MoveWidget(dev=dev, motor='bm1_try', label='TRY', unit='mm', decimals=2, deadband=0.1) self.mover_widgets.append(self.bm1_try) self.bm1_mov_group = Group( @@ -750,22 +820,24 @@ class MoverPanel(QWidget): ) # Focusing Mirror - mot = self.dev.fm_trx - self.fm_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2, deadband=0.01) + self.fm_trx = MoveWidget(dev=dev, motor='fm_trx', label='TRX', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.fm_trx) - mot = self.dev.fm_try - self.fm_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2, deadband=0.01) + self.fm_try = MoveWidget(dev=dev, motor='fm_try', label='TRY', unit='mm', decimals=2, deadband=0.01) self.mover_widgets.append(self.fm_try) - mot = self.dev.fm_bnd - self.fm_bnd = MoveWidget(motor=mot, label='BENDER', unit='km', decimals=2, deadband=0.2) + self.fm_bnd = MoveWidget(dev=dev, motor='fm_bnd', label='BENDER', unit='km', decimals=2, deadband=0.2) self.mover_widgets.append(self.fm_bnd) - mot = self.dev.fm_rotx - self.fm_rotx = MoveWidget(motor=mot, label='PITCH', unit='mrad', decimals=3, deadband=0.01) + self.fm_rotx = MoveWidget(dev=dev, motor='fm_rotx', label='PITCH', unit='mrad', decimals=3, deadband=0.01) self.mover_widgets.append(self.fm_rotx) + self.fm_roty = MoveWidget(dev=dev, motor='fm_roty', label='YAW', unit='mrad', decimals=3, deadband=0.01) + self.mover_widgets.append(self.fm_roty) + + self.fm_rotz = MoveWidget(dev=dev, motor='fm_rotz', label='ROLL', unit='mrad', decimals=3, deadband=0.01) + self.mover_widgets.append(self.fm_rotz) + self.fm_mov_group = Group( 'Focusing Mirror', [ @@ -773,24 +845,28 @@ class MoverPanel(QWidget): self.fm_try, self.fm_bnd, self.fm_rotx, + self.fm_roty, + self.fm_rotz, ] ) # OP Slits 2 - mot = self.dev.sl2_centery - self.sl2_centery = MoveWidget(motor=mot, label='CENTERY', unit='mm', decimals=2, deadband=0.1) + self.sl2_centery = MoveWidget(dev=dev, motor='sl2_centery', label='CENTERY', unit='mm', decimals=2, deadband=0.1) self.mover_widgets.append(self.sl2_centery) + self.sl2_gapy = MoveWidget(dev=dev, motor='sl2_gapy', label='GAPY', unit='mm', decimals=2, deadband=0.1) + self.mover_widgets.append(self.sl2_gapy) + self.sl2_mov_group = Group( 'OP Slits 2', [ self.sl2_centery, + self.sl2_gapy, ] ) # OP Beam Monitor 2 - mot = self.dev.bm2_try - self.bm2_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2, deadband=0.1) + self.bm2_try = MoveWidget(dev=dev, motor='bm2_try', label='TRY', unit='mm', decimals=2, deadband=0.1) self.mover_widgets.append(self.bm2_try) self.bm2_mov_group = Group( @@ -801,23 +877,38 @@ class MoverPanel(QWidget): ) # Optical Table - mot = self.dev.ot_try - self.ot_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2, deadband=0.2) + self.ot_try = MoveWidget(dev=dev, motor='ot_try', label='TRY', unit='mm', decimals=2, deadband=0.2) self.mover_widgets.append(self.ot_try) - mot = self.dev.ot_rotx - self.ot_rotx = MoveWidget(motor=mot, label='ROTX', unit='mrad', decimals=3, deadband=0.05) + self.ot_rotx = MoveWidget(dev=dev, motor='ot_rotx', label='ROTX', unit='mrad', decimals=3, deadband=0.05) self.mover_widgets.append(self.ot_rotx) - mot = self.dev.ot_es1_trz - self.ot_es1_trz = MoveWidget(motor=mot, label='ES1 TRZ', unit='mm', decimals=0, deadband=5) - self.mover_widgets.append(self.ot_es1_trz) - self.ot_mov_group = Group( 'Optical Table', [ self.ot_try, self.ot_rotx, + ] + ) + + # Experimental Station 0 + self.es0wi_try = MoveWidget(dev=dev, motor='es0wi_try', label='ES0 WI', unit='mm', decimals=0, deadband=0.1) + self.mover_widgets.append(self.es0wi_try) + + self.es0_mov_group = Group( + 'Expperimental Station 0', + [ + self.es0wi_try, + ] + ) + + # Experimental Station 1 + self.ot_es1_trz = MoveWidget(dev=dev, motor='ot_es1_trz', label='ES1 TRZ', unit='mm', decimals=0, deadband=5) + self.mover_widgets.append(self.ot_es1_trz) + + self.es1_mov_group = Group( + 'Expperimental Station 1', + [ self.ot_es1_trz, ] ) @@ -827,6 +918,7 @@ class MoverPanel(QWidget): 'Mover', [ self.sldi_mov_group, + self.abs_group, self.cm_mov_group, self.mo1_mov_group, self.sl1_mov_group, @@ -835,6 +927,8 @@ class MoverPanel(QWidget): self.sl2_mov_group, self.bm2_mov_group, self.ot_mov_group, + self.es0_mov_group, + self.es1_mov_group, ] ) @@ -868,10 +962,10 @@ class SurfacePlots(QWidget): } self.plots = { - 'cm': {}, - 'mo1_1': {}, - 'mo1_2': {}, 'fm': {}, + 'mo1_2': {}, + 'mo1_1': {}, + 'cm': {}, } self.color_impenetrable = (0, 0, 0) @@ -961,7 +1055,7 @@ class SurfacePlots(QWidget): pen = pg.mkPen(QColor(*self.colors[idx], 255), width=1, style=Qt.DashLine) else: brush = QBrush(QColor(*self.colors[idx], 255)) - pen = pg.mkPen(QColor(*self.colors[idx], 255), width=1) + pen = pg.mkPen(QColor(*self.colors[idx], 255), width=0) self.plots[name][scene].setPen(pen) self.plots[name][scene].setBrush(brush) @@ -1020,6 +1114,7 @@ class SideviewPlot(QWidget): self.plot_widget = pg.PlotWidget() self.plot_widget.getAxis('bottom').enableAutoSIPrefix(False) + self.plot_widget.invertX(True) self.plot_widget.addLegend() self.color_impenetrable = (0, 0, 0) @@ -1037,15 +1132,18 @@ class SideviewPlot(QWidget): for idx, scene in enumerate(self.data.keys()): if scene in "assistant": - pen = pg.mkPen(color=self.colors[idx], width=2, style=Qt.DashLine) + pen = pg.mkPen(color=self.colors[idx], width=2, style=Qt.DotLine) + z_value = 2 else: pen = pg.mkPen(color=self.colors[idx], width=2) + z_value = 1 self.plots[scene] = self.plot_widget.plot( [], [], pen=pen, name=scene, ) + self.plots[scene].setZValue(z_value) self.plot_group = Group( 'Side View', @@ -1098,7 +1196,7 @@ class SideviewPlot(QWidget): for idx, scene in enumerate(self.data): if scene in 'assistant': brush = QBrush(QColor(*self.colors[idx], 255), Qt.DiagCrossPattern) - pen = pg.mkPen(QColor(*self.colors[idx], 255), width=3, style=Qt.DashLine) + pen = pg.mkPen(QColor(*self.colors[idx], 255), width=3, style=Qt.DotLine) else: brush = QBrush(QColor(*self.colors[idx], 255)) pen = pg.mkPen(QColor(*self.colors[idx], 255), width=3) @@ -1141,7 +1239,7 @@ if __name__ == "__main__": from bec_widgets.utils.colors import apply_theme app = QApplication(sys.argv) - apply_theme("dark") + apply_theme("light") dispatcher = BECDispatcher(gui_id="digital_twin") win = DigitalTwin() win.show() diff --git a/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py b/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py index b3b9a40..27419af 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py @@ -7,6 +7,8 @@ from bec_qthemes import material_icon from bec_widgets.utils.colors import get_accent_colors from bec_lib import bec_logger +from debye_bec.devices.absorber import STATUS as ABS_STATUS + from qtpy.QtCore import Qt, QThread, Signal, QObject, Property, QPropertyAnimation from qtpy.QtWidgets import ( QGroupBox, QHBoxLayout, QVBoxLayout, QLabel, QPushButton, @@ -85,17 +87,17 @@ class StatusIcon(QWidget): class MotionWorker(QObject): """ - Simulates moving a stage from current_pos to target_pos. - Emits position_changed and finished signals. + Executes motion on the specified motor and includes some safety during + motion for certain motors. """ position_changed = Signal(float) error = Signal(bool) # True = error finished = Signal(bool) # True = reached target, False = stopped - def __init__(self, motor, target_pos: float): + def __init__(self, dev, motor, target_pos: float): super().__init__() + self.dev = dev self.motor = motor - self.name = motor.dotted_name self._target = target_pos self._stop_flag = threading.Event() @@ -103,7 +105,7 @@ class MotionWorker(QObject): self._stop_flag.set() def run(self): - logger.info(f'Would run motor {self.name}') + logger.info(f'Would run motor {self.motor}') simulated_run_time = 3 start = time.time() while (time.time() - start) < simulated_run_time: @@ -119,22 +121,139 @@ class MotionWorker(QObject): # time.sleep(0.1) self.finished.emit(True) + def run2(self): + match self.name: + case 'sldi_gapx' | 'sldi_gapy' | 'sldi_centerx' | 'sldi_centery': + self.motion() + case 'cm_trx': + self.motion(abs_closed=True, surveyed_axes=[ + {'device': self.dev['cm_roty'], 'abs_tol': 0.05} + ]) + case 'cm_roty': + self.motion(abs_closed=True, surveyed_axes=[ + {'device': self.dev['cm_trx'], 'abs_tol': 0.05} + ]) + case 'cm_try': + self.motion(abs_closed=True, surveyed_axes=[ + {'device': self.dev['cm_rotx'], 'abs_tol': 0.05}, + {'device': self.dev['cm_rotz'], 'abs_tol': 0.05}, + ]) + case 'cm_rotx': + self.motion(abs_closed=True, surveyed_axes=[ + {'device': self.dev['cm_try'], 'abs_tol': 0.05}, + {'device': self.dev['cm_rotz'], 'abs_tol': 0.05}, + ]) + case 'cm_rotz': + self.motion(abs_closed=True, surveyed_axes=[ + {'device': self.dev['cm_try'], 'abs_tol': 0.05}, + {'device': self.dev['cm_rotx'], 'abs_tol': 0.05}, + ]) + case 'cm_bnd': + p1 = (1/(self.dev.cm_bnd_radius.read['cm_bnd_radius']['value']*1e3) + 0.0284)/2e-6 + p2 = (1/(self._target*1e3) + 0.0284)/2e-6 + self._target = p2 - p1 + self.motion(relative=True, rb= + {'device': self.dev['cm_bnd_radius']} + ) + case 'mo1_try' | 'mo1_trx' | 'mo1_roty': + self.motion(abs_closed=True) + case 'mo1_bragg_angle': + self.motion() + case 'sl1_centery' | 'sl1_gapy' | 'bm1_try': + self.motion() + case 'fm_trx': + self.motion(abs_closed=True, surveyed_axes=[ + {'device': self.dev['fm_roty'], 'abs_tol': 0.05} + ]) + case 'fm_roty': + self.motion(abs_closed=True, surveyed_axes=[ + {'device': self.dev['fm_trx'], 'abs_tol': 0.05} + ]) + case 'fm_try': + self.motion(abs_closed=True, surveyed_axes=[ + {'device': self.dev['fm_rotx'], 'abs_tol': 0.05}, + {'device': self.dev['fm_rotz'], 'abs_tol': 0.05}, + ]) + case 'fm_rotx': + self.motion(abs_closed=True, surveyed_axes=[ + {'device': self.dev['fm_try'], 'abs_tol': 0.05}, + {'device': self.dev['fm_rotz'], 'abs_tol': 0.05}, + ]) + case 'fm_rotz': + self.motion(abs_closed=True, surveyed_axes=[ + {'device': self.dev['fm_try'], 'abs_tol': 0.05}, + {'device': self.dev['fm_rotx'], 'abs_tol': 0.05}, + ]) + case 'fm_bnd': + p1 = (1/(self.dev.fm_bnd_radius.read['fm_bnd_radius']['value']*1e3) + 4.28e-5)/1.84e-9 + p2 = (1/(self._target*1e3) + 4.28e-5)/1.84e-9 + self._target = p2 - p1 + self.motion(relative=True, rb= + {'device': self.dev['fm_bnd_radius']} + ) + case 'sl2_centery' | 'sl2_gapy' | 'bm2_try': + self.motion() + case 'ot_try' | 'ot_rotx' | 'ot_es1_trz': + self.motion() + case _: + logger.warning(f'Motor {self.motor} not integrated in digital twin!') + + def motion(self, abs_closed=False, relative=False, rb=None, surveyed_axes = None): + """ + Moves an axis while surverying a set of axes (if set). + Example surveyed_axes: + [{'device': bec_device_object, 'abs_tol': 0.1},] + + Args: + surveyed_axes (list): List of dictionaries of devices + """ + if abs_closed: + if self.dev.abs.status.get() == ABS_STATUS.OPEN: + status = self.dev.abs.close() + # TODO Set timeout to 0.001 and check if it actually raises (it should not start motion). + # Check of behavior of digital twin afterwards. + status.wait(timeout=5) + if surveyed_axes is not None: + for surv_ax in surveyed_axes: + surv_ax['name'] = surv_ax['device'].dotted_name + surv_ax['old_value'] = surv_ax['device'].read()[surv_ax['name']]['value'] + if rb is not None: + rb['name'] = rb['device'].dotted_name + self.dev[self.motor].move(self._target, relative=relative) + while self.dev[self.motor].motor_is_moving.get(): + if self._stop_flag.is_set(): + self.dev[self.motor].motor_stop() + if rb is not None: + self.position_changed.emit(rb['device'].read()[rb['name']]['value']) + else: + self.position_changed.emit(self.dev[self.motor].read[self.motor]['value']) + if surveyed_axes is not None: + for surv_ax in surveyed_axes: + fb = surv_ax['device'].read()[surv_ax['name']]['value'] + if abs(fb - surv_ax['old_value']) > surv_ax['abs_tol']: + self.dev[self.motor].motor_stop() + self.error.emit(1) + break + time.sleep(0.1) + self.finished.emit(True) + class MoveWidget(QWidget): """ One motor stage control group containing: - - Value spinbox (target position) + - Target label (target position) - Feedback label (current position) - - Status icon (qtawesome) + - Status icon (bec_qthemes) - Start / Stop button """ - def __init__(self, motor, label: str = '', unit=None, decimals=3, deadband=0.0): + def __init__(self, dev, motor, label: str = '', unit=None, decimals=3, deadband=0.0): super().__init__() self.fb = 0.0 self.target = 0 + self.dev = dev self.motor = motor self.deadband = deadband - self._status = Status.IN_POSITION + self.status = Status.IN_POSITION self._thread: QThread | None = None self._worker: MotionWorker | None = None @@ -143,8 +262,6 @@ class MoveWidget(QWidget): self.unit = unit self.decimals = decimals - # self._set_status(Status.IN_POSITION) - layout = QHBoxLayout(self) layout.setContentsMargins(10, 0, 0, 0) layout.setSpacing(0) @@ -206,7 +323,7 @@ class MoveWidget(QWidget): self._on_target_or_fb_changed() def set_feedback(self, fb): - if self._status != Status.MOVING: + if self.status != Status.MOVING: self.fb = fb text = f'{fb:.{int(self.decimals)}f}' if self.unit is not None: @@ -214,9 +331,6 @@ class MoveWidget(QWidget): self.fb_label.setText(text) self._on_target_or_fb_changed() - # ------------------------------------------------------------------ - # Button style helpers - # ------------------------------------------------------------------ def _apply_button_style(self, mode: str): if mode == "start": self.btn_action.setText("▶ Move") @@ -229,19 +343,13 @@ class MoveWidget(QWidget): f"QPushButton {{background-color: {get_accent_colors().emergency.name()}; color: white;}}" ) - # ------------------------------------------------------------------ - # Status management - # ------------------------------------------------------------------ def _set_status(self, status: str): - self._status = status + self.status = status self.status_icon.set_status(status) - # ------------------------------------------------------------------ - # Motion control - # ------------------------------------------------------------------ def _on_target_or_fb_changed(self): """Re-evaluate in-position status whenever the target value changes.""" - if self._status in (Status.ERROR, Status.MOVING): + if self.status in (Status.ERROR, Status.MOVING): return if abs(self.fb - self.target) <= self.deadband: self._set_status(Status.IN_POSITION) @@ -263,11 +371,10 @@ class MoveWidget(QWidget): self._set_status(Status.MOVING) self._apply_button_style("stop") - self._worker = MotionWorker(self.motor, target) + self._worker = MotionWorker(self.dev, self.motor, target) self._thread = QThread() self._worker.moveToThread(self._thread) - # Wire signals self._thread.started.connect(self._worker.run) self._worker.position_changed.connect(self._on_position_changed) self._worker.error.connect(self._on_error) @@ -285,7 +392,6 @@ class MoveWidget(QWidget): def _stop_motion(self): if self._worker: self._worker.stop() - # UI will update via finished signal def _on_position_changed(self, pos: float): self.fb = pos @@ -296,10 +402,11 @@ class MoveWidget(QWidget): def _on_motion_finished(self, reached: bool): target = self.target - if abs(self.fb - target) <= self.deadband: - self._set_status(Status.IN_POSITION) - else: - self._set_status(Status.NOT_IN_POSITION) + if self.status not in Status.ERROR: + if abs(self.fb - target) <= self.deadband: + self._set_status(Status.IN_POSITION) + else: + self._set_status(Status.NOT_IN_POSITION) self._apply_button_style("start") def _cleanup_thread(self): @@ -310,12 +417,82 @@ class MoveWidget(QWidget): self._worker.deleteLater() self._worker = None - # ------------------------------------------------------------------ - # Called on application close — stop motion safely - # ------------------------------------------------------------------ def shutdown(self): if self._worker: self._worker.stop() if self._thread: self._thread.quit() self._thread.wait(2000) # max 2 s grace period + +class AbsorberWidget(QWidget): + """ + Control of the frontend absorber (only open) + """ + + def __init__(self, absorber, label: str = 'Absorber'): + super().__init__() + self.absorber = absorber + self.fb = False + self.text_color = (0, 0, 0) + + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 0, 0, 0) + layout.setSpacing(0) + + # Name + self.label = QLabel(label) + self.label.setFixedWidth(100) + self.label.setContentsMargins(0, 0, 10, 0) + self.label.setWordWrap(True) + layout.addWidget(self.label) + + # Blank + self.blank_label = QLabel('') + self.blank_label.setFixedWidth(100) + layout.addWidget(self.blank_label) + + # Feedback + self.fb_label = QLabel('-') + self.fb_label.setFixedWidth(100) + layout.addWidget(self.fb_label) + + # Blank icon + self.blank_icon = QLabel('') + self.blank_icon.setFixedWidth(30) + self.blank_icon.setContentsMargins(0, 0, 10, 0) + layout.addWidget(self.blank_icon) + + # Open + self.btn_action = QPushButton("Open") + self.btn_action.setFixedWidth(90) + self.btn_action.setFixedHeight(20) + self.btn_action.clicked.connect(self._on_button_clicked) + layout.addWidget(self.btn_action) + + def set_feedback(self, fb: bool): + self.fb = fb + if fb: + self.fb_label.setText('Open') + self.fb_label.setStyleSheet( + f"QLabel {{color: {get_accent_colors().success.name()}}}" + ) + else: + self.fb_label.setText('Closed') + self.fb_label.setStyleSheet( + f"QLabel {{color: {get_accent_colors().emergency.name()}}}" + ) + + def enable_open(self, enable: bool = False): + if enable: + self.btn_action.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().success.name()}; color: white;}}" + ) + self.btn_action.setEnabled(True) + else: # disabled + self.btn_action.setStyleSheet( + "QPushButton {{background-color: rgb(120, 120, 120); color: white;}}" + ) + self.btn_action.setDisabled(True) + + def _on_button_clicked(self): + self.absorber.open() diff --git a/debye_bec/bec_widgets/widgets/qt_widgets.py b/debye_bec/bec_widgets/widgets/qt_widgets.py index 636b4fe..201e72f 100644 --- a/debye_bec/bec_widgets/widgets/qt_widgets.py +++ b/debye_bec/bec_widgets/widgets/qt_widgets.py @@ -8,6 +8,8 @@ from qtpy.QtWidgets import ( from qtpy.QtGui import QFont from qtpy.QtCore import Qt +from bec_widgets.utils.colors import get_accent_colors + class Group(QGroupBox): def __init__(self, label, widgets): super().__init__(label) @@ -128,70 +130,40 @@ class ComboBox(QWidget): def setDisabled(self, disable): self.value.setDisabled(disable) -# class Mover(QWidget): -# def __init__(self, dev, egu, prec): -# super().__init__() -# layout = QHBoxLayout(self) -# layout.setContentsMargins(10, 0, 0, 0) -# layout.setSpacing(0) -# self.position = QLabel('-') -# self.position.setFixedWidth(150) -# layout.addWidget(self.position) -# self.led = QLabel() -# self.led.setFixedWidth(30) -# self.led.setStyleSheet("background-color: 0, 0, 0; border: 1px solid black;") -# layout.addWidget(self.led) -# self.start = QPushButton('Move') -# self.start.setStyleSheet("color: black; background-color: green;") -# self.start.setFixedWidth(80) -# self.stop = QPushButton('Stop') -# self.stop.setStyleSheet("color: black; background-color: firebrick;") -# self.stop.setFixedWidth(80) -# layout.addWidget(self.start) -# layout.addWidget(self.stop) -# self.dev = dev -# self.unit = egu -# self.decimals = prec +class Button(QWidget): + def __init__(self, label=None, label_button:str='', enabled=False): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 0, 0, 0) + layout.setSpacing(0) + if label is not None: + self.label = QLabel(label) + self.label.setFixedWidth(140) + layout.addWidget(self.label) + self.button = QPushButton(label_button) + if label is not None: + self.button.setFixedWidth(160) + self.enable_button(enabled) + layout.addWidget(self.button) -# def led_set_status(self, status): -# if status in 'out': -# self.led.setStyleSheet("background-color: 255, 0, 0; border: 1px solid black;") -# elif status in 'moving': -# self.led.setStyleSheet("background-color: 255, 255, 0; border: 1px solid black;") -# elif status in 'in': -# self.led.setStyleSheet("background-color: 0, 255, 0; border: 1px solid black;") + def clicked_connect(self, func): + """Connect a function to the button press.""" + self.button.clicked.connect(func) -# def position_setValue(self, number): -# text = f'{number:.{int(self.decimals)}f}' -# if self.unit is not None: -# text = text + ' ' + self.unit -# self.position.setText(text) + def enable_button(self, enable: bool = False): + if enable: + self.button.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}" + ) + self.button.setEnabled(True) + else: # disabled + self.button.setStyleSheet( + "QPushButton {{background-color: rgb(120, 120, 120); color: white;}}" + ) + self.button.setDisabled(True) -# def start_clicked_connect(self, func): -# """Connect a function to the start button press.""" -# self.start.clicked.connect( -# partial(func, dev=self.dev) -# ) - -# def stop_clicked_connect(self, func): -# """Connect a function to the stop button press.""" -# self.stop.clicked.connect( -# partial(func, dev=self.dev) -# ) - -# def start_setEnabled(self, enable): -# self.start.setEnabled(enable) -# if enable: -# self.start.setStyleSheet("color: black; background-color: green;") -# else: -# self.start.setStyleSheet("color: black; background-color: grey;") - -# def stop_setEnabled(self, enable): -# self.stop.setEnabled(enable) -# if enable: -# self.stop.setStyleSheet("color: black; background-color: firebrick;") -# else: -# self.stop.setStyleSheet("color: black; background-color: grey;") + def setText(self, text): + self.button.setText(text) # class TextIndicator(QWidget): # def __init__(self, label, unit=None, highlight=False): diff --git a/debye_bec/device_configs/x01da_standard_config.yaml b/debye_bec/device_configs/x01da_standard_config.yaml index 2015fef..a154a73 100644 --- a/debye_bec/device_configs/x01da_standard_config.yaml +++ b/debye_bec/device_configs/x01da_standard_config.yaml @@ -33,15 +33,15 @@ mo1_bragg: onFailure: retry enabled: true softwareTrigger: false -#mo1_bragg_angle: -# readoutPriority: baseline -# description: Positioner for the Monochromator -# deviceClass: debye_bec.devices.mo1_bragg.mo1_bragg_angle.Mo1BraggAngle -# deviceConfig: -# prefix: "X01DA-OP-MO1:BRAGG:" -# onFailure: retry -# enabled: true -# softwareTrigger: false +mo1_bragg_angle: + readoutPriority: baseline + description: Positioner for the Monochromator + deviceClass: debye_bec.devices.mo1_bragg.mo1_bragg_angle.Mo1BraggAngle + deviceConfig: + prefix: "X01DA-OP-MO1:BRAGG:" + onFailure: retry + enabled: true + softwareTrigger: false ## Remaining optics hutch optics_config: diff --git a/debye_bec/devices/absorber.py b/debye_bec/devices/absorber.py index e359357..484054d 100644 --- a/debye_bec/devices/absorber.py +++ b/debye_bec/devices/absorber.py @@ -41,8 +41,8 @@ class Absorber(PSIDeviceBase): USER_ACCESS = ["open", "close"] request = Cpt(EpicsSignal, suffix="REQUEST", kind="config", doc="Open/Close Absorber") - status = Cpt(EpicsSignalRO, suffix="STATUS", kind="config", doc="Absorber Status") - status_string = Cpt(EpicsSignalRO, suffix="STATUS", kind="config", string=True, doc="Absorber Status") + status = Cpt(EpicsSignalRO, suffix="STATUS", kind="normal", doc="Absorber Status") + status_string = Cpt(EpicsSignalRO, suffix="STATUS", kind="normal", string=True, doc="Absorber Status") def __init__(self, *, name: str, prefix: str = "", scan_info: ScanInfo | None = None, **kwargs): super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs) From 5d862e1d5b98cf8a2a5b2b28083a1640d821e317 Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 13 May 2026 08:57:56 +0200 Subject: [PATCH 47/54] Adding additional signals for nidaq and mono --- .../devices/mo1_bragg/mo1_bragg_devices.py | 10 +++++++ debye_bec/devices/nidaq/nidaq.py | 9 +++++++ .../file_writer/debye_nexus_structure.py | 27 ++++++++++++++++--- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py b/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py index bbe0c1b..fe1d5e5 100644 --- a/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py +++ b/debye_bec/devices/mo1_bragg/mo1_bragg_devices.py @@ -80,6 +80,10 @@ class Mo1BraggCrystal(Device): bragg_off_si311 = Cpt(EpicsSignalWithRBV, suffix="bragg_off_si311", kind="config") phi_off_si111 = Cpt(EpicsSignalWithRBV, suffix="phi_off_si111", kind="config") phi_off_si311 = Cpt(EpicsSignalWithRBV, suffix="phi_off_si311", kind="config") + azm_off_si111 = Cpt(EpicsSignalWithRBV, suffix="azm_off_si111", kind="config") + azm_off_si311 = Cpt(EpicsSignalWithRBV, suffix="azm_off_si311", kind="config") + miscut_si111 = Cpt(EpicsSignalWithRBV, suffix="miscut_si111", kind="config") + miscut_si311 = Cpt(EpicsSignalWithRBV, suffix="miscut_si311", kind="config") xtal_enum = Cpt(EpicsSignalWithRBV, suffix="xtal_ENUM", kind="config") d_spacing_si111 = Cpt(EpicsSignalWithRBV, suffix="d_spacing_si111", kind="config") d_spacing_si311 = Cpt(EpicsSignalWithRBV, suffix="d_spacing_si311", kind="config") @@ -93,6 +97,12 @@ class Mo1BraggCrystal(Device): current_phi_off = Cpt( EpicsSignalRO, suffix="current_phi_off_RBV", kind="normal", auto_monitor=True ) + current_azm_off = Cpt( + EpicsSignalRO, suffix="current_azm_off_RBV", kind="normal", auto_monitor=True + ) + current_miscut = Cpt( + EpicsSignalRO, suffix="current_miscut_RBV", kind="normal", auto_monitor=True + ) current_xtal = Cpt( EpicsSignalRO, suffix="current_xtal_ENUM_RBV", kind="normal", auto_monitor=True ) diff --git a/debye_bec/devices/nidaq/nidaq.py b/debye_bec/devices/nidaq/nidaq.py index 35cb6f6..c1564ba 100644 --- a/debye_bec/devices/nidaq/nidaq.py +++ b/debye_bec/devices/nidaq/nidaq.py @@ -38,6 +38,9 @@ class NidaqControl(Device): smpl_abs = Cpt( SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream sample absorption" ) + smpl_fluo = Cpt( + SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream sample fluorescence" + ) ref_abs = Cpt( SetableSignal, value=0, kind=Kind.normal, doc="NIDAQ stream reference absorption" ) @@ -495,6 +498,12 @@ class NidaqControl(Device): smpl_abs_de = Cpt(EpicsSignal, suffix="NIDAQ-smpl_abs_de", kind=Kind.config, auto_monitor=True) smpl_abs_de_string = Cpt(EpicsSignal, suffix="NIDAQ-smpl_abs_de", kind=Kind.config, string=True, auto_monitor=True) + smpl_fluo_no = Cpt(EpicsSignal, suffix="NIDAQ-smpl_fluo_no", kind=Kind.config, auto_monitor=True) + smpl_fluo_no_string = Cpt(EpicsSignal, suffix="NIDAQ-smpl_fluo_no", kind=Kind.config, string=True, auto_monitor=True) + + smpl_fluo_de = Cpt(EpicsSignal, suffix="NIDAQ-smpl_fluo_de", kind=Kind.config, auto_monitor=True) + smpl_fluo_de_string = Cpt(EpicsSignal, suffix="NIDAQ-smpl_fluo_de", kind=Kind.config, string=True, auto_monitor=True) + ref_abs_no = Cpt(EpicsSignal, suffix="NIDAQ-ref_abs_no", kind=Kind.config, auto_monitor=True) ref_abs_no_string = Cpt(EpicsSignal, suffix="NIDAQ-ref_abs_no", kind=Kind.config, string=True, auto_monitor=True) diff --git a/debye_bec/file_writer/debye_nexus_structure.py b/debye_bec/file_writer/debye_nexus_structure.py index f2ff5c0..5a44ad9 100644 --- a/debye_bec/file_writer/debye_nexus_structure.py +++ b/debye_bec/file_writer/debye_nexus_structure.py @@ -79,7 +79,7 @@ class DebyeNexusStructure(DefaultFormat): target="/entry/collection/devices/mo1_bragg/mo1_bragg_crystal_current_phi_off/value", ) phi_offset.attrs["NX_class"] = "NX_FLOAT" - phi_offset.attrs["units"] = "degree" + phi_offset.attrs["units"] = "degree" ## Logic if device exist if "mo1_roty" in self.device_manager.devices: @@ -92,8 +92,19 @@ class DebyeNexusStructure(DefaultFormat): azimuthal_angle.attrs["NX_class"] = "NX_FLOAT" azimuthal_angle.attrs["units"] = "degree" + azm_offset = crystal.create_soft_link( + name="azm_offset", + target="/entry/collection/devices/mo1_bragg/mo1_bragg_crystal_current_azm_off/value", + ) + azm_offset.attrs["NX_class"] = "NX_FLOAT" + azm_offset.attrs["units"] = "degree" - #TODO add phi offset, currently missing from mo1 device, unify device naming (mo vs mo1) + miscut = crystal.create_soft_link( + name="miscut", + target="/entry/collection/devices/mo1_bragg/mo1_bragg_crystal_current_miscut/value", + ) + miscut.attrs["NX_class"] = "NX_FLOAT" + miscut.attrs["units"] = "degree" ################### ### cm mirror specific information @@ -317,11 +328,21 @@ class DebyeNexusStructure(DefaultFormat): main_data.create_soft_link(name="mu_sample", target="/entry/collection/readout_groups/async/nidaq/nidaq_smpl_abs/value") + ################## + ## fluo sample, test whether the signal exists. how to check from config? + ################### + + if (int(add_chans_bits) & (1<<1)) !=0: + mu_sample = main_data.create_group(name="fluo_sample") + mu_sample.attrs["NX_class"] = "NXdata" + + main_data.create_soft_link(name="fluo_sample", target="/entry/collection/readout_groups/async/nidaq/nidaq_smpl_fluo/value") + ################## ## mu reference, test whether the signal exists. how to check from config? ################### - if (int(add_chans_bits) & (1<<1)) !=0: + if (int(add_chans_bits) & (1<<2)) !=0: mu_reference = main_data.create_group(name="mu_reference") mu_reference.attrs["NX_class"] = "NXdata" From c04d829fc65cc776a6b94d0c81143189556313a1 Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 13 May 2026 13:29:09 +0200 Subject: [PATCH 48/54] wip: digital twin --- .../widgets/digital_twin/calc_positions.py | 21 +- .../widgets/digital_twin/calc_sideview.py | 4 +- .../widgets/digital_twin/calc_surfaces.py | 8 +- .../widgets/digital_twin/digital_twin.py | 196 +++++++++++++----- .../widgets/digital_twin/move_widget.py | 58 +++--- .../widgets/digital_twin/offset_settings.py | 58 ++++++ .../bec_widgets/widgets/x01da_offsets.yaml | 50 +++++ 7 files changed, 301 insertions(+), 94 deletions(-) create mode 100644 debye_bec/bec_widgets/widgets/digital_twin/offset_settings.py create mode 100644 debye_bec/bec_widgets/widgets/x01da_offsets.yaml diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calc_positions.py b/debye_bec/bec_widgets/widgets/digital_twin/calc_positions.py index 569145e..55a1cca 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calc_positions.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calc_positions.py @@ -155,15 +155,15 @@ def calc_positions(cfg): # Bender radius if cfg['fm_qy'] is None: - radius = 2 * q / np.sin(cfg['fm_pitch']) # ideal bending radius for focused beam + radius = 2 * q / np.sin(cfg['fm_rotx']) # ideal bending radius for focused beam else: - radius = 2 * cfg['fm_qy'] / np.sin(cfg['fm_pitch']) # ideal bending radius for unfocused beam + radius = 2 * cfg['fm_qy'] / np.sin(cfg['fm_rotx']) # ideal bending radius for unfocused beam pos['fm_bnd_radius'] = {'value': radius * 1e-6} # Convert to km # Pitch d = bl.fm.center[1] - bl.cm.center[1] - dz - fm_pitch = 2 * cfg['cm_pitch'] - cfg['fm_pitch'] # calculate pitch in absolute values (according to horizontal plane) - pos['fm_rotx'] = {'value': -fm_pitch * 1e3} # invert and convert to mrad (same as EGU of rotx axis) + fm_rotx = 2 * cfg['cm_pitch'] - cfg['fm_rotx'] # calculate pitch in absolute values (according to horizontal plane) + pos['fm_rotx'] = {'value': -fm_rotx * 1e3} # invert and convert to mrad (same as EGU of rotx axis) if cfg['fm_stripe'] in ('Rh (toroid)', 'Pt (toroid)'): @@ -205,28 +205,31 @@ def calc_positions(cfg): else: raise Exception('FM Stripe selection not valid') + pos['fm_roty'] = {'value': 0} + pos['fm_rotz'] = {'value': 0} + ## Slits 2 d = bl.opSlits2.center[1] - bl.fm.center[1] - sl2_beam_height = fm_beam_height - d * np.tan(-(2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch'])) + sl2_beam_height = fm_beam_height - d * np.tan(-(2 * cfg['cm_pitch'] - 2 * cfg['fm_rotx'])) pos['sl2_centery'] = {'value': sl2_beam_height} pos['sl2_gapy'] = {'value': beam_vs + 1} # Add 0.5 mm space on both sides of the beam ## Beam Monitor 2 d = bl.opBM2.center[1] - bl.fm.center[1] - bm2_beam_height = fm_beam_height - d * np.tan(-(2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch'])) + bm2_beam_height = fm_beam_height - d * np.tan(-(2 * cfg['cm_pitch'] - 2 * cfg['fm_rotx'])) pos['bm2_try'] = {'value': bm2_beam_height} ## Optical Table # TRY d = bl.ehWindow.center[1] - bl.fm.center[1] - ot_height = fm_beam_height - d * np.tan(-(2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch'])) + ot_height = fm_beam_height - d * np.tan(-(2 * cfg['cm_pitch'] - 2 * cfg['fm_rotx'])) # logger.info(fm_height) - # logger.info(d * np.tan((2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch']))) + # logger.info(d * np.tan((2 * cfg['cm_pitch'] - 2 * cfg['fm_rotx']))) pos['ot_try'] = {'value': ot_height} # Pitch - ot_pitch = - (2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch']) + ot_pitch = - (2 * cfg['cm_pitch'] - 2 * cfg['fm_rotx']) pos['ot_rotx'] = {'value': ot_pitch * 1e3} # TRZ ES1 diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calc_sideview.py b/debye_bec/bec_widgets/widgets/digital_twin/calc_sideview.py index 5a0f930..33487a2 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calc_sideview.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calc_sideview.py @@ -25,12 +25,12 @@ def calc_sideview(cfg): beam['x'].append(bl.fm.center[1]) # FM beam['y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1]-dz)+dy) beam['x'].append(cfg['smpl']) # Experiment - beam['y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1]-dz)+dy+np.tan(2*(cfg['cm_pitch']-cfg['fm_pitch']))*(cfg['smpl']-bl.fm.center[1])) + beam['y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1]-dz)+dy+np.tan(2*(cfg['cm_pitch']-cfg['fm_rotx']))*(cfg['smpl']-bl.fm.center[1])) elif cfg['mo1_mode'] == 'Pinkbeam': beam['x'].append(bl.fm.center[1]) # FM beam['y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1])) beam['x'].append(cfg['smpl']) # Experiment - beam['y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1])+np.tan(2*(cfg['cm_pitch']-cfg['fm_pitch']))*(cfg['smpl']-bl.fm.center[1])) + beam['y'].append(bl.sourceHeight+np.tan(2*cfg['cm_pitch'])*(bl.fm.center[1]-bl.cm.center[1])+np.tan(2*(cfg['cm_pitch']-cfg['fm_rotx']))*(cfg['smpl']-bl.fm.center[1])) dy_fm_ex = beam['y'][-1] - beam['y'][-2] dz_fm_ex = beam['x'][-1] - beam['x'][-2] diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py b/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py index 7162d45..013164b 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/calc_surfaces.py @@ -81,16 +81,16 @@ def calc_surfaces(cfg): if cfg['fm_stripe'] in ('Rh (toroid)', 'Pt (toroid)'): - l = heightBeam/np.sin(cfg['fm_pitch']) + l = heightBeam/np.sin(cfg['fm_rotx']) alpha = np.arccos(1-widthBeam**2/(2*r**2)) h = r-(r*np.cos(alpha/2)) - z = h/np.tan(cfg['fm_pitch']) + z = h/np.tan(cfg['fm_rotx']) x = [off-widthBeam/2, off-widthBeam/2] y = [l/2-z/2, -l/2-z/2] # logger.info(f'stripe: {cfg["fm_stripe"]}') - # logger.info(f'fm_pitch: {cfg["fm_pitch"]}') + # logger.info(f'fm_rotx: {cfg["fm_rotx"]}') # logger.info(f'h: {h}') # logger.info(f'z: {z}') # logger.info(f'r: {r}') @@ -120,7 +120,7 @@ def calc_surfaces(cfg): out['fm']['y'] = y else: # flat surface, no toroid - l = heightBeam/np.sin(cfg['fm_pitch']) + l = heightBeam/np.sin(cfg['fm_rotx']) w1 = 2 * (bl.fm.center[1]-l/2) * np.tan(cfg['h_acc']) w2 = 2 * (bl.fm.center[1]+l/2) * np.tan(cfg['h_acc']) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 6337ca1..a8a5a4a 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -4,6 +4,8 @@ Digital Twin: Custom BEC widget to support the beamline alignment. import sys import numpy as np +import yaml +from pathlib import Path from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints @@ -56,10 +58,13 @@ from debye_bec.bec_widgets.widgets.digital_twin.calc_varia import ( wall_geometries, pipe_geometries, ) +from debye_bec.bec_widgets.widgets.digital_twin.offset_settings import OffsetSettings from debye_bec.devices.absorber import STATUS as ABS_STATUS logger = bec_logger.logger +OFFSET_FILE = "debye_bec/debye_bec/bec_widgets/widgets/x01da_offsets.yaml" + class DigitalTwin(BECWidget, QWidget): """ Main widget of Digital Twin @@ -74,21 +79,28 @@ class DigitalTwin(BECWidget, QWidget): central = QWidget() self.root_layout = QHBoxLayout(central) + + self.input_widget = QWidget() + self.input_layout = QVBoxLayout(self.input_widget) + self.input = InputPanel() + self.settings = SettingsPanel() + self.input_layout.addWidget(self.input) # type: ignore + self.input_layout.addWidget(self.settings) # type: ignore + self.plot_widget = QWidget() self.plot_layout = QVBoxLayout(self.plot_widget) - - self.input = InputPanel() self.sideview_plot = SideviewPlot() self.surface_plots = SurfacePlots() + self.plot_layout.addWidget(self.sideview_plot) # type: ignore + self.plot_layout.addWidget(self.surface_plots) # type: ignore + self.positions = PositionsPanel() self.mover = MoverPanel(self.dev) - self.root_layout.addWidget(self.input, stretch=1, alignment=Qt.AlignTop) # type: ignore - self.plot_layout.addWidget(self.sideview_plot) # type: ignore - self.plot_layout.addWidget(self.surface_plots) # type: ignore - self.root_layout.addWidget(self.plot_widget, stretch=1, alignment=Qt.AlignTop) # type: ignore - # self.root_layout.addWidget(self.positions, stretch=1, alignment=Qt.AlignTop) # type: ignore - self.root_layout.addWidget(self.mover, stretch=1, alignment=Qt.AlignTop) + self.root_layout.addWidget(self.input_widget, alignment=Qt.AlignTop) # type: ignore + self.root_layout.addWidget(self.plot_widget, alignment=Qt.AlignTop) # type: ignore + # self.root_layout.addWidget(self.positions, alignment=Qt.AlignTop) # type: ignore + self.root_layout.addWidget(self.mover, alignment=Qt.AlignTop) self.setLayout(self.root_layout) self.setWindowTitle("Digital Twin") @@ -103,17 +115,21 @@ class DigitalTwin(BECWidget, QWidget): self.input.mo1_xtal.activated_connect(self.calc_assistant) self.input.fm_stripe.activated_connect(self.calc_assistant) self.input.fm_focus.activated_connect(self.calc_assistant) - self.input.fm_pitch.value_changed_connect(self.calc_assistant) + self.input.fm_rotx.value_changed_connect(self.calc_assistant) self.input.fm_focx.value_changed_connect(self.calc_assistant) self.input.fm_focy.value_changed_connect(self.calc_assistant) self.input.smpl.value_changed_connect(self.calc_assistant) self.input.adapt_reality.clicked_connect(self.adapt_reality) + self.settings.reload_offsets.clicked_connect(self.load_offsets) + self.settings.unload_offsets.clicked_connect(self.unload_offsets) self.bragg_angle = 0 self.qy = 0 + self.offsets = {} # Initialize all values + self.load_offsets(recalculate=False) self.calc_assistant(identifier='init') # Timer: update plot every 1 second @@ -181,13 +197,13 @@ class DigitalTwin(BECWidget, QWidget): fm_focus = self.input.fm_focus.currentText() if fm_focus in 'Manual': - fm_pitch = self.input.fm_pitch.value() + fm_rotx = self.input.fm_rotx.value() fm_qy = None elif fm_focus in 'Focused': - fm_pitch = self.input.fm_pitch_ideal.value() + fm_rotx = self.input.fm_rotx_ideal.value() fm_qy = None else: # Focused - fm_pitch = self.input.fm_pitch_ideal.value() + fm_rotx = self.input.fm_rotx_ideal.value() fm_qy = self.qy config = { # Config in SI units! @@ -200,7 +216,7 @@ class DigitalTwin(BECWidget, QWidget): 'mo1_mode' : self.input.mo1_mode.currentText(), 'mo1_xtal' : self.input.mo1_xtal.currentText(), 'mo1_bragg' : self.bragg_angle, - 'fm_pitch' : -fm_pitch * 1e-3, + 'fm_rotx' : -fm_rotx * 1e-3, 'fm_stripe' : self.input.fm_stripe.currentText(), 'fm_trx' : None, 'fm_qy' : fm_qy, @@ -225,8 +241,8 @@ class DigitalTwin(BECWidget, QWidget): cm_pitch = self.dev.cm_rotx.read(cached=True)['cm_rotx']['value'] fm_trx = self.dev.fm_trx.read(cached=True)['fm_trx']['value'] fm_stripe = fm_trx_to_stripe(-fm_trx) - fm_pitch = self.dev.fm_rotx.read(cached=True)['fm_rotx']['value'] - fm_pitch_real = 2 * cm_pitch - fm_pitch + fm_rotx = self.dev.fm_rotx.read(cached=True)['fm_rotx']['value'] + fm_rotx_real = 2 * cm_pitch - fm_rotx smpl = self.dev.ot_es1_trz.read(cached=True)['ot_es1_trz']['value'] config = { # Config in SI units! 'energy' : mo1_bragg['mo1_bragg']['value'], @@ -238,7 +254,7 @@ class DigitalTwin(BECWidget, QWidget): 'mo1_mode' : mo1_mode, 'mo1_xtal' : mo1_bragg['mo1_bragg_crystal_current_xtal_string']['value'], 'mo1_bragg' : mo1_bragg['mo1_bragg_angle']['value']/180*np.pi, - 'fm_pitch' : -fm_pitch_real * 1e-3, + 'fm_rotx' : -fm_rotx_real * 1e-3, 'fm_stripe' : fm_stripe, 'fm_trx' : -fm_trx, 'fm_gain_height' : 1, @@ -253,11 +269,11 @@ class DigitalTwin(BECWidget, QWidget): if mover.status in ('moving', 'error'): ready = False if ready: - self.mover.abs.enable_open(1) # Enable open button + self.mover.abs.enable_open(True) # Enable open button else: - self.mover.abs.enable_open(0) # Disable open button + self.mover.abs.enable_open(False) # Disable open button else: - self.mover.abs.enable_open(0) # Disable open button + self.mover.abs.enable_open(False) # Disable open button self.mover.sldi_gapx.set_feedback(sldi_gapx) self.mover.sldi_gapy.set_feedback(sldi_gapy) @@ -274,7 +290,7 @@ class DigitalTwin(BECWidget, QWidget): self.mover.fm_trx.set_feedback(fm_trx) self.mover.fm_try.set_feedback(self.dev.fm_try.read(cached=True)['fm_try']['value']) self.mover.fm_bnd.set_feedback(self.dev.fm_bnd_radius.read(cached=True)['fm_bnd_radius']['value']) - self.mover.fm_rotx.set_feedback(fm_pitch) + self.mover.fm_rotx.set_feedback(fm_rotx) self.mover.fm_roty.set_feedback(self.dev.fm_roty.read(cached=True)['fm_roty']['value']) self.mover.fm_rotz.set_feedback(self.dev.fm_rotz.read(cached=True)['fm_rotz']['value']) self.mover.sl2_centery.set_feedback(self.dev.sl2_centery.read(cached=True)['sl2_centery']['value']) @@ -288,20 +304,40 @@ class DigitalTwin(BECWidget, QWidget): return config def adapt_reality(self, *args): + pos = {} + pos['sldi_gapx'] = self.dev.sldi_gapx.read(cached=True)['sldi_gapx']['value'] + pos['sldi_gapy'] = self.dev.sldi_gapy.read(cached=True)['sldi_gapy']['value'] + pos['cm_trx'] = self.dev.cm_trx.read(cached=True)['cm_trx']['value'] + pos['cm_rotx'] = self.dev.cm_rotx.read(cached=True)['cm_rotx']['value'] + pos['mo1_trx'] = self.dev.mo1_trx.read(cached=True)['mo1_trx']['value'] + pos['fm_trx'] = self.dev.fm_trx.read(cached=True)['fm_trx']['value'] + pos['fm_rotx'] = self.dev.fm_rotx.read(cached=True)['fm_rotx']['value'] + pos['ot_es1_trz'] = self.dev.ot_es1_trz.read(cached=True)['ot_es1_trz']['value'] + + # Removing offsets + for axis, value in pos.items(): + if axis in self.offsets: + axis_offsets = self.offsets[axis] + if 'modifier' in axis_offsets and 'offset' in axis_offsets: + for idx, rng in enumerate(axis_offsets['modifier']['range']): + if rng[0] < pos[axis_offsets['modifier']['axis']] < rng[1]: + pos[axis] -= axis_offsets['offset'][idx] + break + elif 'offset' in axis_offsets: + pos[axis] -= axis_offsets['offset'] + self.input.energy.set_number(self.dev.mo1_bragg.read(cached=True)['mo1_bragg']['value']) h_acc, v_acc = sldi_gap_to_acc( - self.dev.sldi_gapx.read(cached=True)['sldi_gapx']['value'], - self.dev.sldi_gapy.read(cached=True)['sldi_gapy']['value'] + pos['sldi_gapx'], + pos['sldi_gapy'] ) self.input.sldi_hacc.set_number(h_acc*1e3) self.input.sldi_vacc.set_number(v_acc*1e3) self.input.cm_stripe.set_current_text( - cm_trx_to_stripe(-self.dev.cm_trx.read(cached=True)['cm_trx']['value']) + cm_trx_to_stripe(-pos['cm_trx']) ) - cm_pitch = self.dev.cm_rotx.read(cached=True)['cm_rotx']['value'] - self.input.cm_pitch.set_number(cm_pitch) - mo1_trx = self.dev.mo1_trx.read(cached=True)['mo1_trx']['value'] - if abs(mo1_trx) > 5: + self.input.cm_pitch.set_number(pos['cm_rotx']) + if abs(pos['mo1_trx']) > 5: mo1_mode = 'Monochromatic' else: mo1_mode = 'Pinkbeam' @@ -310,37 +346,56 @@ class DigitalTwin(BECWidget, QWidget): self.dev.mo1_bragg.read(cached=True)['mo1_bragg_crystal_current_xtal_string']['value'] ) self.input.fm_stripe.set_current_text( - fm_trx_to_stripe(-self.dev.fm_trx.read(cached=True)['fm_trx']['value']) + fm_trx_to_stripe(-pos['fm_trx']) ) self.input.fm_focus.set_current_text('Manual') - fm_pitch = self.dev.fm_rotx.read(cached=True)['fm_rotx']['value'] - fm_pitch_real = 2 * cm_pitch - fm_pitch - self.input.fm_pitch.set_number(fm_pitch_real) + fm_rotx_real = 2 * pos['cm_rotx'] - pos['fm_rotx'] + self.input.fm_rotx.set_number(fm_rotx_real) self.input.smpl.set_number( - self.dev.ot_es1_trz.read(cached=True)['ot_es1_trz']['value'] + pos['ot_es1_trz'] ) self.calc_assistant(identifier='init') + def load_offsets(self, recalculate=True, *args): + file = Path(OFFSET_FILE) + if not file.exists(): + raise FileNotFoundError(f"Offset file not found: {OFFSET_FILE}") + + with file.open("r", encoding="utf-8") as f: + data = yaml.safe_load(f) + + if not isinstance(data, dict): + raise ValueError(f"Expected a YAML mapping, got {type(data).__name__}") + + self.offsets = data + + if recalculate: + self.calc_assistant(identifier='init') + + def unload_offsets(self, *args): + self.offsets = {} + self.calc_assistant(identifier='init') + def update_fm_mode(self): fm_focus = self.input.fm_focus.currentText() if fm_focus in 'Manual': - self.input.fm_pitch.setVisible(True) - self.input.fm_pitch_ideal.setVisible(True) + self.input.fm_rotx.setVisible(True) + self.input.fm_rotx_ideal.setVisible(True) self.input.fm_focx.setVisible(False) self.input.fm_focy.setVisible(False) - self.input.fm_pitch_ideal.setLabel('Incidence Angle for focused beam') + self.input.fm_rotx_ideal.setLabel('Incidence Angle for focused beam') elif fm_focus in 'Focused': - self.input.fm_pitch.setVisible(False) - self.input.fm_pitch_ideal.setVisible(True) + self.input.fm_rotx.setVisible(False) + self.input.fm_rotx_ideal.setVisible(True) self.input.fm_focx.setVisible(False) self.input.fm_focy.setVisible(False) - self.input.fm_pitch_ideal.setLabel('Incidence Angle for focused beam') + self.input.fm_rotx_ideal.setLabel('Incidence Angle for focused beam') else: # Defocused - self.input.fm_pitch.setVisible(False) - self.input.fm_pitch_ideal.setVisible(True) + self.input.fm_rotx.setVisible(False) + self.input.fm_rotx_ideal.setVisible(True) self.input.fm_focx.setVisible(True) self.input.fm_focy.setVisible(True) - self.input.fm_pitch_ideal.setLabel('Incidence Angle for defocused beam') + self.input.fm_rotx_ideal.setLabel('Incidence Angle for defocused beam') def calc_reality(self): config = self.get_reality_config() @@ -369,13 +424,13 @@ class DigitalTwin(BECWidget, QWidget): fm_stripe = self.input.fm_stripe.currentText() fm_focus = self.input.fm_focus.currentText() if fm_focus in 'Manual': - fm_pitch = -self.input.fm_pitch.value() * 1e-3 + fm_rotx = -self.input.fm_rotx.value() * 1e-3 else: - fm_pitch = -self.input.fm_pitch_ideal.value() * 1e-3 + fm_rotx = -self.input.fm_rotx_ideal.value() * 1e-3 energy = self.input.energy.value() - self.input.fm_refl.setValue(100 * fm_reflectivity(fm_stripe, fm_pitch, energy)) + self.input.fm_refl.setValue(100 * fm_reflectivity(fm_stripe, fm_rotx, energy)) self.input.fm_refl.setLabel(f"Reflectivity at \n{energy:.0f} eV") - self.input.fm_refl_harm.setValue(100 * fm_reflectivity(fm_stripe, fm_pitch, 3*energy)) + self.input.fm_refl_harm.setValue(100 * fm_reflectivity(fm_stripe, fm_rotx, 3*energy)) self.input.fm_refl_harm.setLabel(f"Reflectivity at \n{3*energy:.0f} eV") def calc_cm_fm_harm_suppr(self): @@ -395,6 +450,19 @@ class DigitalTwin(BECWidget, QWidget): def calc_positions(self): out = calc_positions(self.get_assistant_config()) + + # Apply offsets + for axis, axis_data in out.items(): + if axis in self.offsets: + axis_offsets = self.offsets[axis] + if 'modifier' in axis_offsets and 'offset' in axis_offsets: + for idx, rng in enumerate(axis_offsets['modifier']['range']): + if rng[0] < out[axis_offsets['modifier']['axis']]['value'] < rng[1]: + axis_data['value'] += axis_offsets['offset'][idx] + break + elif 'offset' in axis_offsets: + axis_data['value'] += axis_offsets['offset'] + self.positions.sldi_gapx.setValue(out['sldi_gapx']['value']) self.positions.sldi_gapy.setValue(out['sldi_gapy']['value']) self.positions.cm_trx.setValue(out['cm_trx']['value']) @@ -432,6 +500,8 @@ class DigitalTwin(BECWidget, QWidget): self.mover.fm_try.set_target(out['fm_try']['value']) self.mover.fm_bnd.set_target(out['fm_bnd_radius']['value']) self.mover.fm_rotx.set_target(out['fm_rotx']['value']) + self.mover.fm_roty.set_target(out['fm_roty']['value']) + self.mover.fm_rotz.set_target(out['fm_rotz']['value']) self.mover.sl2_centery.set_target(out['sl2_centery']['value']) self.mover.sl2_gapy.set_target(out['sl2_gapy']['value']) self.mover.bm2_try.set_target(out['bm2_try']['value']) @@ -476,9 +546,9 @@ class DigitalTwin(BECWidget, QWidget): sldi_vacc = self.input.sldi_vacc.value() * 1e-3 fm_focx = self.input.fm_focx.value() fm_focy = self.input.fm_focy.value() - fm_pitch, qy = fm_ideal_pitch(fm_focus, fm_stripe, smpl, sldi_hacc, sldi_vacc, fm_focx, fm_focy) + fm_rotx, qy = fm_ideal_pitch(fm_focus, fm_stripe, smpl, sldi_hacc, sldi_vacc, fm_focx, fm_focy) self.qy = qy - self.input.fm_pitch_ideal.setValue(-fm_pitch * 1e3) + self.input.fm_rotx_ideal.setValue(-fm_rotx * 1e3) def calc_cm_crit_pitch(self): cm_stripe = self.input.cm_stripe.currentText() @@ -545,10 +615,10 @@ class InputPanel(QWidget): # Focusing Mirror self.fm_stripe = ComboBox('fm_stripe', 'Stripe', ['Rh (toroid)', 'Rh (flat)', 'Pt (toroid)', 'Pt (flat)']) self.fm_focus = ComboBox('fm_focus', 'Focus Type', ['Manual', 'Focused', 'Defocused']) - self.fm_pitch = InputNumberField('fm_pitch', 'Incidence Angle', unit='mrad', init=-2.391, decimals=3, single_step=0.01, ll=-10, hl=2) + self.fm_rotx = InputNumberField('fm_rotx', 'Incidence Angle', unit='mrad', init=-2.391, decimals=3, single_step=0.01, ll=-10, hl=2) self.fm_focx = InputNumberField('fm_focx', 'Beam Size Horizontal', unit='mm', init=1, decimals=1, single_step=0.1, ll=0, hl=30) self.fm_focy = InputNumberField('fm_focy', 'Beam Size Vertical', unit='mm', init=1, decimals=1, single_step=0.1, ll=0, hl=10) - self.fm_pitch_ideal = NumberIndicator('Incidence Angle for focused beam', 'mrad', decimals=3) + self.fm_rotx_ideal = NumberIndicator('Incidence Angle for focused beam', 'mrad', decimals=3) self.fm_refl = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.fm_refl_harm = NumberIndicator('Reflectivity at x eV', '%', decimals=0) self.fm_ass_group = Group( @@ -556,10 +626,10 @@ class InputPanel(QWidget): [ self.fm_stripe, self.fm_focus, - self.fm_pitch, + self.fm_rotx, self.fm_focx, self.fm_focy, - self.fm_pitch_ideal, + self.fm_rotx_ideal, self.fm_refl, self.fm_refl_harm, ] @@ -587,6 +657,30 @@ class InputPanel(QWidget): self._layout .addWidget(self.input_group) self._layout .addStretch() +class SettingsPanel(QWidget): + """Right-side control panel: input field, indicator, send, recording.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._layout = QVBoxLayout(self) + self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore + + # Reload offsets + self.reload_offsets = Button(label='Reload Offsets', label_button='Reload', enabled=True) + self.unload_offsets = Button(label='Unload Offsets', label_button='Unload', enabled=True) + + # Assemble complete offset group + self.offset_group = Group( + 'Axes Offsets', + [ + self.reload_offsets, + self.unload_offsets, + ] + ) + + self._layout .addWidget(self.offset_group) + self._layout .addStretch() + class PositionsPanel(QWidget): """Right-side control panel: input field, indicator, send, recording.""" diff --git a/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py b/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py index 27419af..5669385 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py @@ -104,25 +104,25 @@ class MotionWorker(QObject): def stop(self): self._stop_flag.set() + # def run(self): + # logger.info(f'Would run motor {self.motor}') + # simulated_run_time = 3 + # start = time.time() + # while (time.time() - start) < simulated_run_time: + # if self._stop_flag.is_set(): + # break + # time.sleep(0.01) + + # # self.motor.move(self._target, relative=False) + # # while self.motor.motor_is_moving.get(): + # # if self._stop_flag.is_set(): + # # self.motor.motor_stop() + # # self.position_changed.emit(self.motor.read[self.name]['value']) + # # time.sleep(0.1) + # self.finished.emit(True) + def run(self): - logger.info(f'Would run motor {self.motor}') - simulated_run_time = 3 - start = time.time() - while (time.time() - start) < simulated_run_time: - if self._stop_flag.is_set(): - break - time.sleep(0.01) - - # self.motor.move(self._target, relative=False) - # while self.motor.motor_is_moving.get(): - # if self._stop_flag.is_set(): - # self.motor.motor_stop() - # self.position_changed.emit(self.motor.read[self.name]['value']) - # time.sleep(0.1) - self.finished.emit(True) - - def run2(self): - match self.name: + match self.motor: case 'sldi_gapx' | 'sldi_gapy' | 'sldi_centerx' | 'sldi_centery': self.motion() case 'cm_trx': @@ -149,7 +149,7 @@ class MotionWorker(QObject): {'device': self.dev['cm_rotx'], 'abs_tol': 0.05}, ]) case 'cm_bnd': - p1 = (1/(self.dev.cm_bnd_radius.read['cm_bnd_radius']['value']*1e3) + 0.0284)/2e-6 + p1 = (1/(self.dev.cm_bnd_radius.read()['cm_bnd_radius']['value']*1e3) + 0.0284)/2e-6 p2 = (1/(self._target*1e3) + 0.0284)/2e-6 self._target = p2 - p1 self.motion(relative=True, rb= @@ -185,7 +185,7 @@ class MotionWorker(QObject): {'device': self.dev['fm_rotx'], 'abs_tol': 0.05}, ]) case 'fm_bnd': - p1 = (1/(self.dev.fm_bnd_radius.read['fm_bnd_radius']['value']*1e3) + 4.28e-5)/1.84e-9 + p1 = (1/(self.dev.fm_bnd_radius.read()['fm_bnd_radius']['value']*1e3) + 4.28e-5)/1.84e-9 p2 = (1/(self._target*1e3) + 4.28e-5)/1.84e-9 self._target = p2 - p1 self.motion(relative=True, rb= @@ -216,22 +216,24 @@ class MotionWorker(QObject): if surveyed_axes is not None: for surv_ax in surveyed_axes: surv_ax['name'] = surv_ax['device'].dotted_name - surv_ax['old_value'] = surv_ax['device'].read()[surv_ax['name']]['value'] + surv_ax['old_value'] = surv_ax['device'].read(cached=True)[surv_ax['name']]['value'] if rb is not None: rb['name'] = rb['device'].dotted_name self.dev[self.motor].move(self._target, relative=relative) + time.sleep(0.5) while self.dev[self.motor].motor_is_moving.get(): if self._stop_flag.is_set(): - self.dev[self.motor].motor_stop() + self.dev[self.motor].stop() + self._stop_flag.clear() if rb is not None: - self.position_changed.emit(rb['device'].read()[rb['name']]['value']) + self.position_changed.emit(rb['device'].read(cached=True)[rb['name']]['value']) else: - self.position_changed.emit(self.dev[self.motor].read[self.motor]['value']) + self.position_changed.emit(self.dev[self.motor].read(cached=True)[self.motor]['value']) if surveyed_axes is not None: for surv_ax in surveyed_axes: - fb = surv_ax['device'].read()[surv_ax['name']]['value'] + fb = surv_ax['device'].read(cached=True)[surv_ax['name']]['value'] if abs(fb - surv_ax['old_value']) > surv_ax['abs_tol']: - self.dev[self.motor].motor_stop() + self.dev[self.motor].stop() self.error.emit(1) break time.sleep(0.1) @@ -333,12 +335,12 @@ class MoveWidget(QWidget): def _apply_button_style(self, mode: str): if mode == "start": - self.btn_action.setText("▶ Move") + self.btn_action.setText("Move") self.btn_action.setStyleSheet( f"QPushButton {{background-color: {get_accent_colors().success.name()}; color: white;}}" ) else: # stop - self.btn_action.setText("■ Stop") + self.btn_action.setText("Stop") self.btn_action.setStyleSheet( f"QPushButton {{background-color: {get_accent_colors().emergency.name()}; color: white;}}" ) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/offset_settings.py b/debye_bec/bec_widgets/widgets/digital_twin/offset_settings.py new file mode 100644 index 0000000..337a091 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/digital_twin/offset_settings.py @@ -0,0 +1,58 @@ +import sys +import numpy as np +from bec_lib import bec_logger +from bec_lib.endpoints import MessageEndpoints + +# pylint: disable=E0611 +from qtpy.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QApplication, + QLayout, + QLabel, + QPushButton, + QDialog, +) +# pylint: disable=E0611 +from qtpy.QtCore import ( + Qt, + QTimer, +) +from qtpy.QtGui import ( + QColor, + QBrush, + QCloseEvent, +) + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot + +from debye_bec.bec_widgets.widgets.qt_widgets import ( + InputNumberField, + ComboBox, + Group, + NumberIndicator, + Button, +) + +logger = bec_logger.logger + +class OffsetSettings(QDialog): + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Axes Offset Settings") + self.setMinimumSize(300, 150) + + layout = QVBoxLayout() + + label = QLabel("👋 Hello from the secondary window!") + label.setAlignment(Qt.AlignCenter) + + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.close) + + layout.addWidget(label) + layout.addWidget(close_btn) + self.setLayout(layout) \ No newline at end of file diff --git a/debye_bec/bec_widgets/widgets/x01da_offsets.yaml b/debye_bec/bec_widgets/widgets/x01da_offsets.yaml new file mode 100644 index 0000000..e1d40a0 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/x01da_offsets.yaml @@ -0,0 +1,50 @@ +cm_try: + offset: 0.15 + +mo1_trx: + modifier: + axis: mo1_trx + range: [[-30, -0.1], [0.1, 30]] + offset: [0, 2.21] + +mo1_try: + modifier: + axis: mo1_trx + range: [[-30, -0.1], [0.1, 30]] + offset: [0, -1.6] + +sl1_centery: + offset: -1.8 + +fm_trx: + modifier: + axis: fm_trx + range: [[-66, -31], [-24, 7], [11, 31], [38, 66]] + offset: [0, 0, 0, -0.16] + +fm_try: + modifier: + axis: fm_trx + range: [[-66, -31], [-24, 7], [11, 31], [38, 66]] + offset: [0, 0, 0, -0.45] + +fm_rotx: + modifier: + axis: fm_trx + range: [[-66, -31], [-24, 7], [11, 31], [38, 66]] + offset: [0, 0, 0, 0.063] + +fm_roty: + modifier: + axis: fm_trx + range: [[-66, -31], [-24, 7], [11, 31], [38, 66]] + offset: [0, 0, 0, -0.04] + +sl2_centery: + offset: 1.2 + +ot_try: + offset: 0 + +ot_rotx: + offset: 0 \ No newline at end of file From a75320ccbc5649507b0098f8d287fdca87a40d78 Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 13 May 2026 16:17:12 +0200 Subject: [PATCH 49/54] refactoring, bugfix theme move buttons --- .../widgets/digital_twin/digital_twin.py | 1 - .../widgets/digital_twin/move_widget.py | 13 ++++- .../widgets/digital_twin/offset_settings.py | 58 ------------------- 3 files changed, 12 insertions(+), 60 deletions(-) delete mode 100644 debye_bec/bec_widgets/widgets/digital_twin/offset_settings.py diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index a8a5a4a..2a5b9fd 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -58,7 +58,6 @@ from debye_bec.bec_widgets.widgets.digital_twin.calc_varia import ( wall_geometries, pipe_geometries, ) -from debye_bec.bec_widgets.widgets.digital_twin.offset_settings import OffsetSettings from debye_bec.devices.absorber import STATUS as ABS_STATUS logger = bec_logger.logger diff --git a/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py b/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py index 5669385..7834a29 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py @@ -292,11 +292,12 @@ class MoveWidget(QWidget): layout.addWidget(self.status_icon) # Start / Stop button - self.btn_action = QPushButton("▶ Move") + self.btn_action = QPushButton("Move") self.btn_action.setFixedWidth(90) self.btn_action.setFixedHeight(20) self.btn_action.clicked.connect(self._on_button_clicked) layout.addWidget(self.btn_action) + self.btn_mode = 'start' self._apply_button_style("start") @@ -316,6 +317,15 @@ class MoveWidget(QWidget): r, g, b = self.text_color['fb'] self.fb_label.setStyleSheet(f'QLabel {{color: rgb({r}, {g}, {b})}}') + if self.btn_mode == 'start': + self.btn_action.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().success.name()}; color: white;}}" + ) + else: + self.btn_action.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().emergency.name()}; color: white;}}" + ) + def set_target(self, target): self.target = target text = f'{target:.{int(self.decimals)}f}' @@ -334,6 +344,7 @@ class MoveWidget(QWidget): self._on_target_or_fb_changed() def _apply_button_style(self, mode: str): + self.btn_mode = mode if mode == "start": self.btn_action.setText("Move") self.btn_action.setStyleSheet( diff --git a/debye_bec/bec_widgets/widgets/digital_twin/offset_settings.py b/debye_bec/bec_widgets/widgets/digital_twin/offset_settings.py deleted file mode 100644 index 337a091..0000000 --- a/debye_bec/bec_widgets/widgets/digital_twin/offset_settings.py +++ /dev/null @@ -1,58 +0,0 @@ -import sys -import numpy as np -from bec_lib import bec_logger -from bec_lib.endpoints import MessageEndpoints - -# pylint: disable=E0611 -from qtpy.QtWidgets import ( - QWidget, - QVBoxLayout, - QHBoxLayout, - QApplication, - QLayout, - QLabel, - QPushButton, - QDialog, -) -# pylint: disable=E0611 -from qtpy.QtCore import ( - Qt, - QTimer, -) -from qtpy.QtGui import ( - QColor, - QBrush, - QCloseEvent, -) - -from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.error_popups import SafeSlot - -from debye_bec.bec_widgets.widgets.qt_widgets import ( - InputNumberField, - ComboBox, - Group, - NumberIndicator, - Button, -) - -logger = bec_logger.logger - -class OffsetSettings(QDialog): - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Axes Offset Settings") - self.setMinimumSize(300, 150) - - layout = QVBoxLayout() - - label = QLabel("👋 Hello from the secondary window!") - label.setAlignment(Qt.AlignCenter) - - close_btn = QPushButton("Close") - close_btn.clicked.connect(self.close) - - layout.addWidget(label) - layout.addWidget(close_btn) - self.setLayout(layout) \ No newline at end of file From 7b1ea281a36617367a1887ebc33df1b67f5d8fa2 Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 18 May 2026 06:57:11 +0200 Subject: [PATCH 50/54] Changed from EpicsMotor to EpicsMotorEC --- .../x01da_experimental_hutch.yaml | 52 +++++++-------- debye_bec/device_configs/x01da_frontend.yaml | 42 ++++++------ debye_bec/device_configs/x01da_optics.yaml | 66 +++++++++---------- debye_bec/device_configs/x01da_xrd.yaml | 12 ++-- 4 files changed, 86 insertions(+), 86 deletions(-) diff --git a/debye_bec/device_configs/x01da_experimental_hutch.yaml b/debye_bec/device_configs/x01da_experimental_hutch.yaml index 720d9f1..f2c241e 100644 --- a/debye_bec/device_configs/x01da_experimental_hutch.yaml +++ b/debye_bec/device_configs/x01da_experimental_hutch.yaml @@ -5,7 +5,7 @@ ot_tryu: readoutPriority: baseline description: Optical Table Y-Translation Upstream - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES-OT:TRYU onFailure: retry @@ -15,7 +15,7 @@ ot_tryu: ot_tryd: readoutPriority: baseline description: Optical Table Y-Translation Downstream - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES-OT:TRYD onFailure: retry @@ -25,7 +25,7 @@ ot_tryd: ot_es1_trz: readoutPriority: baseline description: Optical Table ES1 Z-Translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES1-OT:TRZ onFailure: retry @@ -35,7 +35,7 @@ ot_es1_trz: ot_es2_trz: readoutPriority: baseline description: Optical Table ES2 Z-Translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES2-OT:TRZ onFailure: retry @@ -45,7 +45,7 @@ ot_es2_trz: ot_try: readoutPriority: baseline description: Optical Table Y-Translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES-OT:TRY onFailure: retry @@ -55,7 +55,7 @@ ot_try: ot_rotx: readoutPriority: baseline description: Optical Table Pitch - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES-OT:ROTX onFailure: retry @@ -69,7 +69,7 @@ ot_rotx: es0wi_try: readoutPriority: baseline description: End Station 0 Exit Window Y-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES0-WI:TRY onFailure: retry @@ -97,7 +97,7 @@ es0filter: es0sl_trxr: readoutPriority: baseline description: End Station slits X-translation Ring-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES0-SL:TRXR onFailure: retry @@ -107,7 +107,7 @@ es0sl_trxr: es0sl_trxw: readoutPriority: baseline description: End Station slits X-translation Wall-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES0-SL:TRXW onFailure: retry @@ -117,7 +117,7 @@ es0sl_trxw: es0sl_tryb: readoutPriority: baseline description: End Station slits Y-translation Bottom-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES0-SL:TRYB onFailure: retry @@ -127,7 +127,7 @@ es0sl_tryb: es0sl_tryt: readoutPriority: baseline description: End Station slits X-translation Top-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES0-SL:TRYT onFailure: retry @@ -137,7 +137,7 @@ es0sl_tryt: es0sl_center: readoutPriority: baseline description: End Station slits X-center - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES0-SL:CENTERX onFailure: retry @@ -147,7 +147,7 @@ es0sl_center: es0sl_gapx: readoutPriority: baseline description: End Station slits X-gap - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES0-SL:GAPX onFailure: retry @@ -157,7 +157,7 @@ es0sl_gapx: es0sl_centery: readoutPriority: baseline description: End Station slits Y-center - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES0-SL:CENTERY onFailure: retry @@ -167,7 +167,7 @@ es0sl_centery: es0sl_gapy: readoutPriority: baseline description: End Station slits Y-gap - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES0-SL:GAPY onFailure: retry @@ -195,7 +195,7 @@ es1_alignment_laser: es1man_trx: readoutPriority: baseline description: End Station sample manipulator X-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES1-MAN1:TRX onFailure: retry @@ -205,7 +205,7 @@ es1man_trx: es1man_try: readoutPriority: baseline description: End Station sample manipulator Y-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES1-MAN1:TRY onFailure: retry @@ -215,7 +215,7 @@ es1man_try: es1man_trz: readoutPriority: baseline description: End Station sample manipulator Z-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES1-MAN1:TRZ onFailure: retry @@ -225,7 +225,7 @@ es1man_trz: es1man_roty: readoutPriority: baseline description: End Station sample manipulator Y-rotation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES1-MAN1:ROTY onFailure: retry @@ -239,7 +239,7 @@ es1man_roty: es1arc_roty: readoutPriority: baseline description: End Station segmented arc Y-rotation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES1-ARC:ROTY onFailure: retry @@ -249,7 +249,7 @@ es1arc_roty: es1det1_trx: readoutPriority: baseline description: End Station SDD 1 X-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES1-DET1:TRX onFailure: retry @@ -259,7 +259,7 @@ es1det1_trx: es1bm1_trx: readoutPriority: baseline description: End Station X-ray Eye X-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES1-BM1:TRX onFailure: retry @@ -269,7 +269,7 @@ es1bm1_trx: es1det2_trx: readoutPriority: baseline description: End Station SDD 2 X-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES1-DET2:TRX onFailure: retry @@ -283,7 +283,7 @@ es1det2_trx: es2ma2_try: readoutPriority: baseline description: End Station ionization chamber 1+2 Y-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES2-MA2:TRY onFailure: retry @@ -293,7 +293,7 @@ es2ma2_try: es2ma2_trz: readoutPriority: baseline description: End Station ionization chamber 1+2 Z-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES2-MA2:TRZ onFailure: retry @@ -307,7 +307,7 @@ es2ma2_trz: es2ma3_try: readoutPriority: baseline description: End Station XRD detector Y-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES2-MA3:TRY onFailure: retry diff --git a/debye_bec/device_configs/x01da_frontend.yaml b/debye_bec/device_configs/x01da_frontend.yaml index 9532428..3a9edb7 100644 --- a/debye_bec/device_configs/x01da_frontend.yaml +++ b/debye_bec/device_configs/x01da_frontend.yaml @@ -20,7 +20,7 @@ abs: sldi_trxr: readoutPriority: baseline description: Front-end slit diaphragm X-translation Ring-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-SLDI:TRXR onFailure: retry @@ -30,7 +30,7 @@ sldi_trxr: sldi_trxw: readoutPriority: baseline description: Front-end slit diaphragm X-translation Wall-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-SLDI:TRXW onFailure: retry @@ -40,7 +40,7 @@ sldi_trxw: sldi_tryb: readoutPriority: baseline description: Front-end slit diaphragm Y-translation Bottom-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-SLDI:TRYB onFailure: retry @@ -50,7 +50,7 @@ sldi_tryb: sldi_tryt: readoutPriority: baseline description: Front-end slit diaphragm X-translation Top-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-SLDI:TRYT onFailure: retry @@ -60,7 +60,7 @@ sldi_tryt: sldi_centerx: readoutPriority: baseline description: Front-end slit diaphragm X-center - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-SLDI:CENTERX onFailure: retry @@ -70,7 +70,7 @@ sldi_centerx: sldi_gapx: readoutPriority: baseline description: Front-end slit diaphragm X-gap - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-SLDI:GAPX onFailure: retry @@ -80,7 +80,7 @@ sldi_gapx: sldi_centery: readoutPriority: baseline description: Front-end slit diaphragm Y-center - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-SLDI:CENTERY onFailure: retry @@ -90,7 +90,7 @@ sldi_centery: sldi_gapy: readoutPriority: baseline description: Front-end slit diaphragm Y-gap - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-SLDI:GAPY onFailure: retry @@ -104,7 +104,7 @@ sldi_gapy: cm_trxu: readoutPriority: baseline description: Collimating Mirror X-translation upstream - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-CM:TRXU onFailure: retry @@ -114,7 +114,7 @@ cm_trxu: cm_trxd: readoutPriority: baseline description: Collimating Mirror X-translation downstream - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-CM:TRXD onFailure: retry @@ -124,7 +124,7 @@ cm_trxd: cm_tryu: readoutPriority: baseline description: Collimating Mirror Y-translation upstream - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-CM:TRYU onFailure: retry @@ -134,7 +134,7 @@ cm_tryu: cm_trydr: readoutPriority: baseline description: Collimating Mirror Y-translation downstream ring - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-CM:TRYDR onFailure: retry @@ -144,7 +144,7 @@ cm_trydr: cm_trydw: readoutPriority: baseline description: Collimating Mirror Y-translation downstream wall - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-CM:TRYDW onFailure: retry @@ -154,7 +154,7 @@ cm_trydw: cm_bnd: readoutPriority: baseline description: Collimating Mirror bender - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-CM:BND onFailure: retry @@ -175,7 +175,7 @@ cm_bnd_radius: cm_rotx: readoutPriority: baseline description: Collimating Morror Pitch - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-CM:ROTX onFailure: retry @@ -185,7 +185,7 @@ cm_rotx: cm_roty: readoutPriority: baseline description: Collimating Morror Yaw - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-CM:ROTY onFailure: retry @@ -195,7 +195,7 @@ cm_roty: cm_rotz: readoutPriority: baseline description: Collimating Morror Roll - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-CM:ROTZ onFailure: retry @@ -205,7 +205,7 @@ cm_rotz: cm_trx: readoutPriority: baseline description: Collimating Morror Center Point X - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-CM:XTCP onFailure: retry @@ -215,7 +215,7 @@ cm_trx: cm_try: readoutPriority: baseline description: Collimating Morror Center Point Y - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-CM:YTCP onFailure: retry @@ -225,7 +225,7 @@ cm_try: cm_ztcp: readoutPriority: baseline description: Collimating Morror Center Point Z - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-CM:ZTCP onFailure: retry @@ -235,7 +235,7 @@ cm_ztcp: cm_xstripe: readoutPriority: baseline description: Collimating Morror X Stripe - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-FE-CM:XSTRIPE onFailure: retry diff --git a/debye_bec/device_configs/x01da_optics.yaml b/debye_bec/device_configs/x01da_optics.yaml index d362794..a168e92 100644 --- a/debye_bec/device_configs/x01da_optics.yaml +++ b/debye_bec/device_configs/x01da_optics.yaml @@ -6,7 +6,7 @@ mo1_try: readoutPriority: baseline description: Monochromator Y Translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-MO1:TRY onFailure: retry @@ -16,7 +16,7 @@ mo1_try: mo1_trx: readoutPriority: baseline description: Monochromator X Translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-MO1:TRX onFailure: retry @@ -26,7 +26,7 @@ mo1_trx: mo1_roty: readoutPriority: baseline description: Monochromator Yaw - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-MO1:ROTY onFailure: retry @@ -40,7 +40,7 @@ mo1_roty: sl1_trxr: readoutPriority: baseline description: Optics slits 1 X-translation Ring-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL1:TRXR onFailure: retry @@ -53,7 +53,7 @@ sl1_trxr: sl1_trxw: readoutPriority: baseline description: Optics slits 1 X-translation Wall-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL1:TRXW onFailure: retry @@ -66,7 +66,7 @@ sl1_trxw: sl1_tryb: readoutPriority: baseline description: Optics slits 1 Y-translation Bottom-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL1:TRYB onFailure: retry @@ -79,7 +79,7 @@ sl1_tryb: sl1_tryt: readoutPriority: baseline description: Optics slits 1 X-translation Top-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL1:TRYT onFailure: retry @@ -92,7 +92,7 @@ sl1_tryt: bm1_try: readoutPriority: baseline description: Beam Monitor 1 Y-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-BM1:TRY onFailure: retry @@ -105,7 +105,7 @@ bm1_try: sl1_centerx: readoutPriority: baseline description: Optics slits 1 X-center - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL1:CENTERX onFailure: retry @@ -118,7 +118,7 @@ sl1_centerx: sl1_gapx: readoutPriority: baseline description: Optics slits 1 X-gap - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL1:GAPX onFailure: retry @@ -131,7 +131,7 @@ sl1_gapx: sl1_centery: readoutPriority: baseline description: Optics slits 1 Y-center - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL1:CENTERY onFailure: retry @@ -144,7 +144,7 @@ sl1_centery: sl1_gapy: readoutPriority: baseline description: Optics slits 1 Y-gap - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL1:GAPY onFailure: retry @@ -161,7 +161,7 @@ sl1_gapy: fm_trxu: readoutPriority: baseline description: Focusing Mirror X-translation upstream - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-FM:TRXU onFailure: retry @@ -171,7 +171,7 @@ fm_trxu: fm_trxd: readoutPriority: baseline description: Focusing Mirror X-translation downstream - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-FM:TRXD onFailure: retry @@ -181,7 +181,7 @@ fm_trxd: fm_tryd: readoutPriority: baseline description: Focusing Mirror Y-translation downstream - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-FM:TRYD onFailure: retry @@ -191,7 +191,7 @@ fm_tryd: fm_tryur: readoutPriority: baseline description: Focusing Mirror Y-translation upstream ring - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-FM:TRYUR onFailure: retry @@ -201,7 +201,7 @@ fm_tryur: fm_tryuw: readoutPriority: baseline description: Focusing Mirror Y-translation upstream wall - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-FM:TRYUW onFailure: retry @@ -211,7 +211,7 @@ fm_tryuw: fm_bnd: readoutPriority: baseline description: Focusing Mirror bender - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-FM:BND onFailure: retry @@ -232,7 +232,7 @@ fm_bnd_radius: fm_rotx: readoutPriority: baseline description: Focusing Morror Pitch - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-FM:ROTX onFailure: retry @@ -242,7 +242,7 @@ fm_rotx: fm_roty: readoutPriority: baseline description: Focusing Morror Yaw - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-FM:ROTY onFailure: retry @@ -252,7 +252,7 @@ fm_roty: fm_rotz: readoutPriority: baseline description: Focusing Morror Roll - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-FM:ROTZ onFailure: retry @@ -262,7 +262,7 @@ fm_rotz: fm_trx: readoutPriority: baseline description: Focusing Morror Center Point X - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-FM:XTCP onFailure: retry @@ -272,7 +272,7 @@ fm_trx: fm_try: readoutPriority: baseline description: Focusing Morror Center Point Y - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-FM:YTCP onFailure: retry @@ -282,7 +282,7 @@ fm_try: fm_ztcp: readoutPriority: baseline description: Focusing Morror Center Point Z - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-FM:ZTCP onFailure: retry @@ -296,7 +296,7 @@ fm_ztcp: sl2_trxr: readoutPriority: baseline description: Optics slits 2 X-translation Ring-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL2:TRXR onFailure: retry @@ -309,7 +309,7 @@ sl2_trxr: sl2_trxw: readoutPriority: baseline description: Optics slits 2 X-translation Wall-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL2:TRXW onFailure: retry @@ -322,7 +322,7 @@ sl2_trxw: sl2_tryb: readoutPriority: baseline description: Optics slits 2 Y-translation Bottom-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL2:TRYB onFailure: retry @@ -335,7 +335,7 @@ sl2_tryb: sl2_tryt: readoutPriority: baseline description: Optics slits 2 X-translation Top-edge - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL2:TRYT onFailure: retry @@ -348,7 +348,7 @@ sl2_tryt: bm2_try: readoutPriority: baseline description: Beam Monitor 2 Y-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-BM2:TRY onFailure: retry @@ -361,7 +361,7 @@ bm2_try: sl2_centerx: readoutPriority: baseline description: Optics slits 2 X-center - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL2:CENTERX onFailure: retry @@ -374,7 +374,7 @@ sl2_centerx: sl2_gapx: readoutPriority: baseline description: Optics slits 2 X-gap - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL2:GAPX onFailure: retry @@ -387,7 +387,7 @@ sl2_gapx: sl2_centery: readoutPriority: baseline description: Optics slits 2 Y-center - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL2:CENTERY onFailure: retry @@ -400,7 +400,7 @@ sl2_centery: sl2_gapy: readoutPriority: baseline description: Optics slits 2 Y-gap - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-OP-SL2:GAPY onFailure: retry diff --git a/debye_bec/device_configs/x01da_xrd.yaml b/debye_bec/device_configs/x01da_xrd.yaml index 961cece..c363ff8 100644 --- a/debye_bec/device_configs/x01da_xrd.yaml +++ b/debye_bec/device_configs/x01da_xrd.yaml @@ -6,7 +6,7 @@ pin1_trx: readoutPriority: baseline description: Pinhole X-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES1-PIN1:TRX onFailure: retry @@ -17,7 +17,7 @@ pin1_trx: pin1_try: readoutPriority: baseline description: Pinhole Y-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES1-PIN1:TRY onFailure: retry @@ -28,7 +28,7 @@ pin1_try: pin1_rotx: readoutPriority: baseline description: Pinhole X-rotation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES1-PIN1:ROTX onFailure: retry @@ -39,7 +39,7 @@ pin1_rotx: pin1_roty: readoutPriority: baseline description: Pinhole Y-rotation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES1-PIN1:ROTY onFailure: retry @@ -54,7 +54,7 @@ pin1_roty: es2bs_trx: readoutPriority: baseline description: End Station beamstop X-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES2-BS:TRX onFailure: retry @@ -64,7 +64,7 @@ es2bs_trx: es2bs_try: readoutPriority: baseline description: End Station beamstop Y-translation - deviceClass: ophyd.EpicsMotor + deviceClass: ophyd_devices.EpicsMotorEC deviceConfig: prefix: X01DA-ES2-BS:TRY onFailure: retry From 7797ce1980038bdcc6b28b053545383d5dc93a74 Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 18 May 2026 07:38:46 +0200 Subject: [PATCH 51/54] fix mo1_bragg test --- tests/tests_devices/test_mo1_bragg.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/tests_devices/test_mo1_bragg.py b/tests/tests_devices/test_mo1_bragg.py index 9dc2e31..e1fd819 100644 --- a/tests/tests_devices/test_mo1_bragg.py +++ b/tests/tests_devices/test_mo1_bragg.py @@ -52,7 +52,7 @@ def test_init(mock_bragg): dev = mock_bragg assert dev.name == "bragg" assert dev.prefix == "X01DA-OP-MO1:BRAGG:" - assert dev.crystal.offset_si111._read_pvname == "X01DA-OP-MO1:BRAGG:offset_si111_RBV" + assert dev.crystal.bragg_off_si111._read_pvname == "X01DA-OP-MO1:BRAGG:bragg_off_si111_RBV" assert dev.move_abs._read_pvname == "X01DA-OP-MO1:BRAGG:move_abs" @@ -106,14 +106,14 @@ def test_set_xtal(mock_bragg): dev = mock_bragg dev.set_xtal("111") # Default values for mock - assert dev.crystal.offset_si111.get() == 0 - assert dev.crystal.offset_si311.get() == 0 + assert dev.crystal.bragg_off_si111.get() == 0 + assert dev.crystal.bragg_off_si311.get() == 0 assert dev.crystal.d_spacing_si111.get() == 0 assert dev.crystal.d_spacing_si311.get() == 0 assert dev.crystal.xtal_enum.get() == 0 - dev.set_xtal("311", offset_si111=1, offset_si311=2, d_spacing_si111=3, d_spacing_si311=4) - assert dev.crystal.offset_si111.get() == 1 - assert dev.crystal.offset_si311.get() == 2 + dev.set_xtal("311", bragg_off_si111=1, bragg_off_si311=2, d_spacing_si111=3, d_spacing_si311=4) + assert dev.crystal.bragg_off_si111.get() == 1 + assert dev.crystal.bragg_off_si311.get() == 2 assert dev.crystal.d_spacing_si111.get() == 3 assert dev.crystal.d_spacing_si311.get() == 4 assert dev.crystal.xtal_enum.get() == 1 From 5d6d0535afc3915bbee2f40711257d62c47a4f6f Mon Sep 17 00:00:00 2001 From: appel_c Date: Mon, 18 May 2026 08:36:30 +0200 Subject: [PATCH 52/54] test: remove scan_motors to fix tests --- tests/tests_scans/test_mono_bragg_scans.py | 4 ---- tests/tests_scans/test_nidaq_continous_scan.py | 1 - 2 files changed, 5 deletions(-) diff --git a/tests/tests_scans/test_mono_bragg_scans.py b/tests/tests_scans/test_mono_bragg_scans.py index e0bafd6..789b24b 100644 --- a/tests/tests_scans/test_mono_bragg_scans.py +++ b/tests/tests_scans/test_mono_bragg_scans.py @@ -66,7 +66,6 @@ def test_xas_simple_scan(scan_assembler, ScanStubStatusMock): device=None, action="open_scan", parameter={ - "scan_motors": [], "readout_priority": { "monitored": [], "baseline": [], @@ -168,7 +167,6 @@ def test_xas_simple_scan_with_xrd(scan_assembler, ScanStubStatusMock): device=None, action="open_scan", parameter={ - "scan_motors": [], "readout_priority": { "monitored": [], "baseline": [], @@ -263,7 +261,6 @@ def test_xas_advanced_scan(scan_assembler, ScanStubStatusMock): device=None, action="open_scan", parameter={ - "scan_motors": [], "readout_priority": { "monitored": [], "baseline": [], @@ -366,7 +363,6 @@ def test_xas_advanced_scan_with_xrd(scan_assembler, ScanStubStatusMock): device=None, action="open_scan", parameter={ - "scan_motors": [], "readout_priority": { "monitored": [], "baseline": [], diff --git a/tests/tests_scans/test_nidaq_continous_scan.py b/tests/tests_scans/test_nidaq_continous_scan.py index f71cbc2..4596d84 100644 --- a/tests/tests_scans/test_nidaq_continous_scan.py +++ b/tests/tests_scans/test_nidaq_continous_scan.py @@ -60,7 +60,6 @@ def test_xas_simple_scan(scan_assembler, ScanStubStatusMock): device=None, action="open_scan", parameter={ - "scan_motors": [], "readout_priority": { "monitored": [], "baseline": [], From 823142b2966d63a06d629bdcd81b4cfbc5b5a78a Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 18 May 2026 09:03:05 +0200 Subject: [PATCH 53/54] Add config checker --- .../widgets/digital_twin/digital_twin.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 2a5b9fd..7e2b163 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -16,6 +16,11 @@ from qtpy.QtWidgets import ( QHBoxLayout, QApplication, QLayout, + QMessageBox, + QLabel, + QDialog, + QPushButton, + QStyle, ) # pylint: disable=E0611 from qtpy.QtCore import ( @@ -76,6 +81,9 @@ class DigitalTwin(BECWidget, QWidget): super().__init__(parent=parent, theme_update=True, *arg, **kwargs) self.get_bec_shortcuts() + # Check if devices are all in config + self.check_config() + central = QWidget() self.root_layout = QHBoxLayout(central) @@ -142,6 +150,84 @@ class DigitalTwin(BECWidget, QWidget): self.surface_plots.apply_theme(theme) self.mover.apply_theme(theme) + def check_config(self): + devices = [ + 'abs', + 'sldi_gapx', + 'sldi_gapy', + 'cm_trx', + 'cm_try', + 'cm_bnd_radius', + 'cm_rotx', + 'mo1_bragg', + 'mo1_trx', + 'mo1_try', + 'sl1_centery', + 'sl1_gapy', + 'bm1_try', + 'fm_trx', + 'fm_try', + 'fm_bnd_radius', + 'fm_rotx', + 'fm_roty', + 'fm_rotz', + 'sl2_centery', + 'sl2_gapy', + 'bm2_try', + 'ot_try', + 'ot_rotx', + 'es0wi_try', + 'ot_es1_trz', + ] + while True: + missing = [d for d in devices if d not in self.dev] + if not missing: + break + dialog = QDialog() + dialog.setWindowTitle("Digital Twin - Config Check") + dialog.setFixedWidth(400) + layout = QVBoxLayout() + + top = QHBoxLayout() + icon = QLabel() + icon_pixmap = QApplication.style().standardIcon( + QStyle.SP_MessageBoxWarning + ).pixmap(48, 48) + icon.setPixmap(icon_pixmap) + icon.setAlignment(Qt.AlignTop) + top.addWidget(icon) + + text = QLabel( + "The current config does not include all required devices to run Digital Twin." + + "Reload the config with the correct devices." + ) + text.setWordWrap(True) + text.setAlignment(Qt.AlignTop) + top.addWidget(text, stretch=1) + layout.addLayout(top) + + info = QLabel("Missing devices:\n" + ", ".join(missing)) + info.setWordWrap(True) + info.setAlignment(Qt.AlignTop) + layout.addWidget(info) + layout.addStretch() + + buttons = QHBoxLayout() + check_again = QPushButton("Check Again") + close_app = QPushButton("Close Application") + check_again.clicked.connect(dialog.accept) + close_app.clicked.connect(dialog.reject) + buttons.addWidget(check_again) + buttons.addWidget(close_app) + layout.addLayout(buttons) + + dialog.setLayout(layout) + dialog.show() + info.setMinimumHeight(info.heightForWidth(info.width())) + if dialog.exec_() == QDialog.Rejected: + QApplication.instance().exit(0) + sys.exit(0) + @SafeSlot() def calc_assistant(self, *args, **kwargs): identifier = kwargs['identifier'] @@ -226,6 +312,8 @@ class DigitalTwin(BECWidget, QWidget): return config def get_reality_config(self): + # Assure all devices are in the config + self.check_config() mo1_trx = self.dev.mo1_trx.read(cached=True)['mo1_trx']['value'] if abs(mo1_trx) > 5: mo1_mode = 'Monochromatic' From 3e80b0fd8dafc4a103872ff46871677b2ff163c6 Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 18 May 2026 09:03:23 +0200 Subject: [PATCH 54/54] Automatic generated files for digital twin --- debye_bec/bec_widgets/widgets/client.py | 22 ++++++++++++++----- .../bec_widgets/widgets/designer_plugins.py | 13 +++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 debye_bec/bec_widgets/widgets/designer_plugins.py diff --git a/debye_bec/bec_widgets/widgets/client.py b/debye_bec/bec_widgets/widgets/client.py index 91294ab..cb2df5d 100644 --- a/debye_bec/bec_widgets/widgets/client.py +++ b/debye_bec/bec_widgets/widgets/client.py @@ -12,20 +12,30 @@ logger = bec_logger.logger # pylint: skip-file -_Widgets = {} +_Widgets = { + "DigitalTwin": "DigitalTwin", +} class DigitalTwin(RPCBase): - """A simple BEC widget with:""" + """Main widget of Digital Twin""" + + _IMPORT_MODULE = "debye_bec.bec_widgets.widgets.digital_twin.digital_twin" @rpc_call - def set_a(self, value: float): + def remove(self): """ - Set input A remotely from the BEC CLI. + Cleanup the BECConnector """ @rpc_call - def set_b(self, value: float): + def attach(self): """ - Set input B remotely from the BEC CLI. + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ diff --git a/debye_bec/bec_widgets/widgets/designer_plugins.py b/debye_bec/bec_widgets/widgets/designer_plugins.py new file mode 100644 index 0000000..c941b27 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/designer_plugins.py @@ -0,0 +1,13 @@ +# This file was automatically generated by generate_cli.py +# type: ignore +from __future__ import annotations + +# pylint: skip-file + +designer_plugins = { + "DigitalTwin": ("debye_bec.bec_widgets.widgets.digital_twin.digital_twin", "DigitalTwin"), +} + +widget_icons = { + "DigitalTwin": "lightbulb", +}