From d7aa60499dbb2156248858bea819d3e96121b357 Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 4 Feb 2026 12:08:42 -0500 Subject: [PATCH 1/9] draft: adding de-tuning to mono object --- src/cditools/motors.py | 45 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/cditools/motors.py b/src/cditools/motors.py index 9107727..bb89511 100644 --- a/src/cditools/motors.py +++ b/src/cditools/motors.py @@ -75,6 +75,50 @@ class DMM(Device): zoff = Cpt(EpicsMotor, "Mono:DMM-Ax:TZ}Mtr") +#### IDK IF WE NEED THESE CLASSES ################################## + +# Setup HDCM +class HDCMPiezoRoll(PVPositionerPC): + setpoint = Cpt(EpicsSignal, "") + readback = Cpt(EpicsSignalRO, "") + # pid_enabled = Cpt( + # EpicsSignal, + # "XF:05IDD-CT{FbPid:01}PID:on", + # name="pid_enabled", + # add_prefix=() + # ) + # pid_I = Cpt( + # EpicsSignal, + # "XF:05IDD-CT{FbPid:01}PID.I", + # name="pid_I", + # add_prefix=() + # ) + + def reset_pid(self): + yield from bps.mov(self.pid_I, 0.0) + + +class HDCMPiezoPitch(PVPositionerPC): + setpoint = Cpt(EpicsSignal, "") + readback = Cpt(EpicsSignalRO, "") + # pid_enabled = Cpt( + # EpicsSignal, + # "XF:05IDD-CT{FbPid:02}PID:on", + # name="pid_enabled", + # add_prefix=() + # ) + # pid_I = Cpt( + # EpicsSignal, + # "XF:05IDD-CT{FbPid:02}PID.I", + # name="pid_I", + # add_prefix=() + # ) + + def reset_pid(self): + yield from bps.mov(self.pid_I, 0.0) + +################################################################## + class DCMBase(Device): pitch = Cpt(EpicsMotor, "Mono:HDCM-Ax:Pitch}Mtr") fine: ClassVar[dict] = { @@ -90,6 +134,7 @@ class Energy(PseudoPositioner): cgap = Cpt(EpicsMotor, "Mono:HDCM-Ax:HG}Mtr") # Synthetic Axis energy = Cpt(PseudoSingle, egu="KeV") + c2_x = energy.c2_x # Energy "limits" _low = 5.0 # TODO: CHECK THIS VALUE From 49e94c55d0f6fd9d1d02750b4596cfa824b106b9 Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 4 Feb 2026 12:13:23 -0500 Subject: [PATCH 2/9] precommit --- src/cditools/motors.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/cditools/motors.py b/src/cditools/motors.py index bb89511..882d740 100644 --- a/src/cditools/motors.py +++ b/src/cditools/motors.py @@ -2,9 +2,18 @@ from typing import ClassVar +import bluesky.plan_stubs as bps import numpy as np from ophyd import Component as Cpt # type: ignore[import-not-found] -from ophyd import Device, EpicsMotor, PseudoPositioner, PseudoSingle +from ophyd import ( + Device, + EpicsMotor, + EpicsSignal, + EpicsSignalRO, + PseudoPositioner, + PseudoSingle, + PVPositionerPC, +) from ophyd import DynamicDeviceComponent as DDC from ophyd.pseudopos import ( pseudo_position_argument, @@ -77,6 +86,7 @@ class DMM(Device): #### IDK IF WE NEED THESE CLASSES ################################## + # Setup HDCM class HDCMPiezoRoll(PVPositionerPC): setpoint = Cpt(EpicsSignal, "") @@ -117,8 +127,10 @@ class HDCMPiezoPitch(PVPositionerPC): def reset_pid(self): yield from bps.mov(self.pid_I, 0.0) + ################################################################## + class DCMBase(Device): pitch = Cpt(EpicsMotor, "Mono:HDCM-Ax:Pitch}Mtr") fine: ClassVar[dict] = { From 5ab85e571de45eda941779fad6bed8619c96324a Mon Sep 17 00:00:00 2001 From: jennmald Date: Thu, 12 Feb 2026 16:22:33 -0500 Subject: [PATCH 3/9] add more work for the detuning scan plus the beginning of the mono peakup scan --- src/cditools/hdcmscans.py | 99 +++++++++++++++++++++++++++++++++++++++ src/cditools/motors.py | 76 ++++++++++-------------------- 2 files changed, 123 insertions(+), 52 deletions(-) create mode 100644 src/cditools/hdcmscans.py diff --git a/src/cditools/hdcmscans.py b/src/cditools/hdcmscans.py new file mode 100644 index 0000000..cee3ff8 --- /dev/null +++ b/src/cditools/hdcmscans.py @@ -0,0 +1,99 @@ +import numpy as np +import matplotlib.pyplot as plt +import bluesky.plan_stubs as bps +import skbeam.core.constants.xrf as xrfC + +interestinglist = ['Si', 'P', 'S', 'Cl', 'Ar', 'K', 'Ca', 'Sc', 'Ti', 'V', + 'Cr', 'Mn', 'Fe', 'Co', 'Ni', 'Cu', 'Zn', 'Ga', 'Ge', 'As', + 'Se', 'Br', 'Kr', 'Rb', 'Sr', 'Y', 'Zr', 'Nb', 'Mo', 'Tc', + 'Ru', 'Rh', 'Pd', 'Ag', 'Cd', 'In', 'Sn', 'Sb', 'Te', 'I', + 'Xe', 'Cs', 'Ba', 'La', 'Ce', 'Pr', 'Nd', 'Pm', 'Sm', 'Eu', + 'Gd', 'Tb', 'Dy', 'Ho', 'Er', 'Tm', 'Yb', 'Lu', 'Hf', 'Ta', + 'W', 'Re', 'Os', 'Ir', 'Pt', 'Au', 'Hg', 'Tl', 'Pb', 'Bi', + 'Po', 'At', 'Rn', 'Fr', 'Ra', 'Ac', 'Th', 'Pa', 'U'] + +elements = dict() +element_edges = ['ka1', 'ka2', 'kb1', 'la1', 'la2', 'lb1', 'lb2', 'lg1', 'ma1'] +element_transitions = ['k', 'l1', 'l2', 'l3', 'm1', 'm2', 'm3', 'm4', 'm5'] +for i in interestinglist: + elements[i] = xrfC.XrfElement(i) + +def getemissionE(element: str, edge: str | None = None) -> float | None: + cur_element = xrfC.XrfElement(element) + if edge is None: + print("Edge\tEnergy [keV]") + for e in element_edges: + if cur_element.emission_line[e] < 25. and \ + cur_element.emission_line[e] > 1.: + # print("{0:s}\t{1:8.2f}".format(e, cur_element.emission_line[e])) + print(f"{e}\t{cur_element.emission_line[e]:8.2f}") + else: + return np.round(cur_element.emission_line[edge], 3) + + +def getbindingsE(element, edge=None): + if edge is None: + y = [0., 'k'] + print("Edge\tEnergy [eV]\tYield") + for i in ['k', 'l1', 'l2', 'l3']: + print(f"{i}\t" + f"{xrfC.XrayLibWrap(elements[element].Z,'binding_e')[i]*1000.:8.2f}\t" + f"{xrfC.XrayLibWrap(elements[element].Z,'yield')[i]:5.3f}") + if (y[0] < xrfC.XrayLibWrap(elements[element].Z, 'yield')[i] and + xrfC.XrayLibWrap(elements[element].Z, 'binding_e')[i] < 25.): + y[0] = xrfC.XrayLibWrap(elements[element].Z, 'yield')[i] + y[1] = i + return np.round(xrfC.XrayLibWrap(elements[element].Z, 'binding_e')[y[1]] * 1000., 3) + else: + return np.round(xrfC.XrayLibWrap(elements[element].Z, 'binding_e')[edge] * 1000., 3) + + +def setroi(roinum: int, element: str, edge: str | None = None, det: object | None = None): + ''' + Set energy ROIs for Vortex SDD. + Selects elemental edge given current energy if not provided. + element element symbol for target energy + edge optional: ['ka1', 'ka2', 'kb1', 'la1', 'la2', + 'lb1', 'lb2', 'lg1', 'ma1'] + det optional: detector object + ''' + cur_element = xrfC.XrfElement(element) + if edge is None: + for e in ['ka1', 'ka2', 'kb1', 'la1', 'la2', + 'lb1', 'lb2', 'lg1', 'ma1']: + if cur_element.emission_line[e] < energy.energy.get()[1]: + edge = 'e' + break + else: + e = edge + + e_ch = int(cur_element.emission_line[e] * 1000) + if det is not None: + channels = [det.channels.channel01, ] + else: + channels = list(xs.iterate_channels()) + + +def mono_peakup(element, acquisition_time=1.0, peakup=True): + """ + First draft of the mono peakup scan + Need more info about the axis to be scanned, the move ID, and which detector will be used for feedback. + Args: + element (string): element name + acquisition_time (float, optional): _description_. Defaults to 1.0. + peakup (bool, optional): _description_. Defaults to True. + """ + getemissionE(element) + energy_x = getbindingsE(element) + + yield from mov(energy, energy_x) + setroi(1, element) + if peakup: + yield from bps.sleep(5) + yield from peakup() + yield from xanes_plan(erange=[energy_x-100,energy_x+50], + estep=[1.0], + samplename=f'{element}Foil', + filename=f'{element}Foilstd', + acqtime=acquisition_time, + shutter=True) \ No newline at end of file diff --git a/src/cditools/motors.py b/src/cditools/motors.py index 882d740..52fcf45 100644 --- a/src/cditools/motors.py +++ b/src/cditools/motors.py @@ -13,6 +13,7 @@ PseudoPositioner, PseudoSingle, PVPositionerPC, + Signal, ) from ophyd import DynamicDeviceComponent as DDC from ophyd.pseudopos import ( @@ -83,54 +84,6 @@ class DMM(Device): ) zoff = Cpt(EpicsMotor, "Mono:DMM-Ax:TZ}Mtr") - -#### IDK IF WE NEED THESE CLASSES ################################## - - -# Setup HDCM -class HDCMPiezoRoll(PVPositionerPC): - setpoint = Cpt(EpicsSignal, "") - readback = Cpt(EpicsSignalRO, "") - # pid_enabled = Cpt( - # EpicsSignal, - # "XF:05IDD-CT{FbPid:01}PID:on", - # name="pid_enabled", - # add_prefix=() - # ) - # pid_I = Cpt( - # EpicsSignal, - # "XF:05IDD-CT{FbPid:01}PID.I", - # name="pid_I", - # add_prefix=() - # ) - - def reset_pid(self): - yield from bps.mov(self.pid_I, 0.0) - - -class HDCMPiezoPitch(PVPositionerPC): - setpoint = Cpt(EpicsSignal, "") - readback = Cpt(EpicsSignalRO, "") - # pid_enabled = Cpt( - # EpicsSignal, - # "XF:05IDD-CT{FbPid:02}PID:on", - # name="pid_enabled", - # add_prefix=() - # ) - # pid_I = Cpt( - # EpicsSignal, - # "XF:05IDD-CT{FbPid:02}PID.I", - # name="pid_I", - # add_prefix=() - # ) - - def reset_pid(self): - yield from bps.mov(self.pid_I, 0.0) - - -################################################################## - - class DCMBase(Device): pitch = Cpt(EpicsMotor, "Mono:HDCM-Ax:Pitch}Mtr") fine: ClassVar[dict] = { @@ -146,23 +99,42 @@ class Energy(PseudoPositioner): cgap = Cpt(EpicsMotor, "Mono:HDCM-Ax:HG}Mtr") # Synthetic Axis energy = Cpt(PseudoSingle, egu="KeV") - c2_x = energy.c2_x + + egu = Cpt(Signal, None, add_prefix=(), value='keV', kind="config") + motor_egu = Cpt(Signal, None, add_prefix=(), value='eV', kind="config") + + ### not sure what PV should be used for the insertion device, example from SRX + # u_gap = Cpt(InsertionDevice, "SR:C5-ID:G1{IVU21:1") + # _u_gap_offset = 0 + ### same for c2_x + # c2_x = Cpt(EpicsMotor, "XF:05IDA-OP:1{Mono:HDCM-Ax:X2}Mtr", add_prefix=(), read_attrs=["user_readback"]) + # epics_d_spacing = EpicsSignal("XF:05IDA-CT{IOC:Status01}DCMDspacing.VAL") + # epics_bragg_offset = EpicsSignal("XF:05IDA-CT{IOC:Status01}BraggOffset.VAL") # Energy "limits" - _low = 5.0 # TODO: CHECK THIS VALUE - _high = 15.0 # TODO: CHECK THIS VALUE + _low = 5.0 # TODO: CHECK THIS VALUE, SRX uses 4.4 + _high = 15.0 # TODO: CHECK THIS VALUE, SRX uses 25 # Set up constants Xoffset = 20.0 # mm d_111 = 3.1286911960950756 ANG_OVER_KEV = 12.3984 + # Motor enable flags + move_u_gap = Cpt(Signal, None, add_prefix=(), value=True) + move_c2_x = Cpt(Signal, None, add_prefix=(), value=True) + harmonic = Cpt(Signal, None, add_prefix=(), value=0, kind="config") + selected_harmonic = Cpt(Signal, None, add_prefix=(), value=0) + + # Experimental + detune = Cpt(Signal, None, add_prefix=(), value=0) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.energy.readback.name = "energy" self.energy.setpoint.name = "energy_setpoint" - def energy_to_positions(self, target_energy: float): + def energy_to_positions(self, target_energy: float, undulator_harmonic, u_detune): """Compute undulator and mono positions given a target energy Parameters From b12b54aa5b0c2ba3bd8c6ff3285ef6ceb145a492 Mon Sep 17 00:00:00 2001 From: jennmald Date: Thu, 12 Feb 2026 16:28:56 -0500 Subject: [PATCH 4/9] add bragg rbv --- src/cditools/motors.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/cditools/motors.py b/src/cditools/motors.py index 52fcf45..12e71f4 100644 --- a/src/cditools/motors.py +++ b/src/cditools/motors.py @@ -149,9 +149,12 @@ def energy_to_positions(self, target_energy: float, undulator_harmonic, u_detune gap : float The gap position in millimeters """ - + # Set up constants + delta_bragg = self._delta_bragg + # Calculate Bragg RBV - bragg = np.arcsin((self.ANG_OVER_KEV / target_energy) / (2 * self.d_111)) + bragg_RBV = np.arcsin((self.ANG_OVER_KEV / target_energy) / (2 * self.d_111)) - delta_bragg + bragg = bragg_RBV + delta_bragg # Calculate C2X gap = self.Xoffset / 2 / np.cos(bragg) From f9fc7958b17f79ce245b4da8e3f97bbe7b2be613 Mon Sep 17 00:00:00 2001 From: jennmald Date: Tue, 17 Feb 2026 11:42:28 -0500 Subject: [PATCH 5/9] energy class updates --- src/cditools/motors.py | 56 +++++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/src/cditools/motors.py b/src/cditools/motors.py index 12e71f4..30d3e8d 100644 --- a/src/cditools/motors.py +++ b/src/cditools/motors.py @@ -20,7 +20,8 @@ pseudo_position_argument, real_position_argument, ) - +from pathlib import Path +from scipy.interpolate import make_interp_spline class EpicsMotorRO(EpicsMotor): def __init__(self, *args, **kwargs): @@ -105,7 +106,7 @@ class Energy(PseudoPositioner): ### not sure what PV should be used for the insertion device, example from SRX # u_gap = Cpt(InsertionDevice, "SR:C5-ID:G1{IVU21:1") - # _u_gap_offset = 0 + _u_gap_offset = 0 ### same for c2_x # c2_x = Cpt(EpicsMotor, "XF:05IDA-OP:1{Mono:HDCM-Ax:X2}Mtr", add_prefix=(), read_attrs=["user_readback"]) # epics_d_spacing = EpicsSignal("XF:05IDA-CT{IOC:Status01}DCMDspacing.VAL") @@ -129,18 +130,41 @@ class Energy(PseudoPositioner): # Experimental detune = Cpt(Signal, None, add_prefix=(), value=0) - def __init__(self, *args, **kwargs): + def __init__(self, *args, delta_bragg:int = 0, xoffset:int = 0, C2Xcal:int = 0, T2cal:int = 0, **kwargs): super().__init__(*args, **kwargs) + self._delta_bragg = delta_bragg + self._xoffset = xoffset + self._C2Xcal = C2Xcal + self._T2cal = T2cal self.energy.readback.name = "energy" self.energy.setpoint.name = "energy_setpoint" - - def energy_to_positions(self, target_energy: float, undulator_harmonic, u_detune): + calib_path = Path(__file__).parent + # this is temporary, we need to figure out if there is a calib file for CDI + calib_file = "../data/CDIUgapCalibration.txt" + + with open(calib_path / calib_file, "r") as f: + next(f) + uposlistIn = [] + elistIn = [] + for line in f: + num = [float(x) for x in line.split()] + # Check in case there is an extra line at the end of the calibration file + if len(num) == 2: + uposlistIn.append(num[0]) + elistIn.append(num[1]) + self.etoulookup = make_interp_spline(elistIn, uposlistIn) + + def energy_to_positions(self, target_energy: float, undulator_harmonic: int, u_detune: float): """Compute undulator and mono positions given a target energy Parameters ---------- target_energy : float Target energy in keV + undulator_harmonic : int + The harmonic in the undulator to use + u_detune : float + Amount to 'mistune' the undulator in keV Returns ------- @@ -148,18 +172,26 @@ def energy_to_positions(self, target_energy: float, undulator_harmonic, u_detune The angle to set the monocromotor in radians gap : float The gap position in millimeters - """ - # Set up constants - delta_bragg = self._delta_bragg + C2X : float + The C2X position in millimeters + ugap : float + The undulator gap position in microns + """ # Calculate Bragg RBV - bragg_RBV = np.arcsin((self.ANG_OVER_KEV / target_energy) / (2 * self.d_111)) - delta_bragg - bragg = bragg_RBV + delta_bragg + bragg_RBV = np.arcsin((self.ANG_OVER_KEV / target_energy) / (2 * self.d_111)) - self._delta_bragg + bragg = bragg_RBV + self._delta_bragg + T2 = self._xoffset + np.sin(bragg * np.pi /180) / np.sin(2 * bragg * np.pi /180) + dT2 = T2 - self._T2cal + C2X = self._C2Xcal - dT2 # Calculate C2X - gap = self.Xoffset / 2 / np.cos(bragg) + gap = self._xoffset / 2 / np.cos(bragg) + ugap = float(self.etoulookup((target_energy + u_detune) / undulator_harmonic)) + ugap *= 1000 + ugap = ugap + self._u_gap_offset - return bragg, gap + return bragg, gap, C2X, ugap @pseudo_position_argument def forward(self, p_pos): From f91fe778b5b0168cd11457c02f328d362f0e046b Mon Sep 17 00:00:00 2001 From: jennmald Date: Tue, 17 Feb 2026 12:50:14 -0500 Subject: [PATCH 6/9] remove hdcm scans and focus only on motors --- src/cditools/hdcmscans.py | 99 --------------------------------------- src/cditools/motors.py | 84 +++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 114 deletions(-) delete mode 100644 src/cditools/hdcmscans.py diff --git a/src/cditools/hdcmscans.py b/src/cditools/hdcmscans.py deleted file mode 100644 index cee3ff8..0000000 --- a/src/cditools/hdcmscans.py +++ /dev/null @@ -1,99 +0,0 @@ -import numpy as np -import matplotlib.pyplot as plt -import bluesky.plan_stubs as bps -import skbeam.core.constants.xrf as xrfC - -interestinglist = ['Si', 'P', 'S', 'Cl', 'Ar', 'K', 'Ca', 'Sc', 'Ti', 'V', - 'Cr', 'Mn', 'Fe', 'Co', 'Ni', 'Cu', 'Zn', 'Ga', 'Ge', 'As', - 'Se', 'Br', 'Kr', 'Rb', 'Sr', 'Y', 'Zr', 'Nb', 'Mo', 'Tc', - 'Ru', 'Rh', 'Pd', 'Ag', 'Cd', 'In', 'Sn', 'Sb', 'Te', 'I', - 'Xe', 'Cs', 'Ba', 'La', 'Ce', 'Pr', 'Nd', 'Pm', 'Sm', 'Eu', - 'Gd', 'Tb', 'Dy', 'Ho', 'Er', 'Tm', 'Yb', 'Lu', 'Hf', 'Ta', - 'W', 'Re', 'Os', 'Ir', 'Pt', 'Au', 'Hg', 'Tl', 'Pb', 'Bi', - 'Po', 'At', 'Rn', 'Fr', 'Ra', 'Ac', 'Th', 'Pa', 'U'] - -elements = dict() -element_edges = ['ka1', 'ka2', 'kb1', 'la1', 'la2', 'lb1', 'lb2', 'lg1', 'ma1'] -element_transitions = ['k', 'l1', 'l2', 'l3', 'm1', 'm2', 'm3', 'm4', 'm5'] -for i in interestinglist: - elements[i] = xrfC.XrfElement(i) - -def getemissionE(element: str, edge: str | None = None) -> float | None: - cur_element = xrfC.XrfElement(element) - if edge is None: - print("Edge\tEnergy [keV]") - for e in element_edges: - if cur_element.emission_line[e] < 25. and \ - cur_element.emission_line[e] > 1.: - # print("{0:s}\t{1:8.2f}".format(e, cur_element.emission_line[e])) - print(f"{e}\t{cur_element.emission_line[e]:8.2f}") - else: - return np.round(cur_element.emission_line[edge], 3) - - -def getbindingsE(element, edge=None): - if edge is None: - y = [0., 'k'] - print("Edge\tEnergy [eV]\tYield") - for i in ['k', 'l1', 'l2', 'l3']: - print(f"{i}\t" - f"{xrfC.XrayLibWrap(elements[element].Z,'binding_e')[i]*1000.:8.2f}\t" - f"{xrfC.XrayLibWrap(elements[element].Z,'yield')[i]:5.3f}") - if (y[0] < xrfC.XrayLibWrap(elements[element].Z, 'yield')[i] and - xrfC.XrayLibWrap(elements[element].Z, 'binding_e')[i] < 25.): - y[0] = xrfC.XrayLibWrap(elements[element].Z, 'yield')[i] - y[1] = i - return np.round(xrfC.XrayLibWrap(elements[element].Z, 'binding_e')[y[1]] * 1000., 3) - else: - return np.round(xrfC.XrayLibWrap(elements[element].Z, 'binding_e')[edge] * 1000., 3) - - -def setroi(roinum: int, element: str, edge: str | None = None, det: object | None = None): - ''' - Set energy ROIs for Vortex SDD. - Selects elemental edge given current energy if not provided. - element element symbol for target energy - edge optional: ['ka1', 'ka2', 'kb1', 'la1', 'la2', - 'lb1', 'lb2', 'lg1', 'ma1'] - det optional: detector object - ''' - cur_element = xrfC.XrfElement(element) - if edge is None: - for e in ['ka1', 'ka2', 'kb1', 'la1', 'la2', - 'lb1', 'lb2', 'lg1', 'ma1']: - if cur_element.emission_line[e] < energy.energy.get()[1]: - edge = 'e' - break - else: - e = edge - - e_ch = int(cur_element.emission_line[e] * 1000) - if det is not None: - channels = [det.channels.channel01, ] - else: - channels = list(xs.iterate_channels()) - - -def mono_peakup(element, acquisition_time=1.0, peakup=True): - """ - First draft of the mono peakup scan - Need more info about the axis to be scanned, the move ID, and which detector will be used for feedback. - Args: - element (string): element name - acquisition_time (float, optional): _description_. Defaults to 1.0. - peakup (bool, optional): _description_. Defaults to True. - """ - getemissionE(element) - energy_x = getbindingsE(element) - - yield from mov(energy, energy_x) - setroi(1, element) - if peakup: - yield from bps.sleep(5) - yield from peakup() - yield from xanes_plan(erange=[energy_x-100,energy_x+50], - estep=[1.0], - samplename=f'{element}Foil', - filename=f'{element}Foilstd', - acqtime=acquisition_time, - shutter=True) \ No newline at end of file diff --git a/src/cditools/motors.py b/src/cditools/motors.py index 30d3e8d..9770c9e 100644 --- a/src/cditools/motors.py +++ b/src/cditools/motors.py @@ -1,18 +1,15 @@ from __future__ import annotations +from pathlib import Path from typing import ClassVar -import bluesky.plan_stubs as bps import numpy as np from ophyd import Component as Cpt # type: ignore[import-not-found] from ophyd import ( Device, EpicsMotor, - EpicsSignal, - EpicsSignalRO, PseudoPositioner, PseudoSingle, - PVPositionerPC, Signal, ) from ophyd import DynamicDeviceComponent as DDC @@ -20,9 +17,9 @@ pseudo_position_argument, real_position_argument, ) -from pathlib import Path from scipy.interpolate import make_interp_spline + class EpicsMotorRO(EpicsMotor): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -85,6 +82,7 @@ class DMM(Device): ) zoff = Cpt(EpicsMotor, "Mono:DMM-Ax:TZ}Mtr") + class DCMBase(Device): pitch = Cpt(EpicsMotor, "Mono:HDCM-Ax:Pitch}Mtr") fine: ClassVar[dict] = { @@ -100,9 +98,9 @@ class Energy(PseudoPositioner): cgap = Cpt(EpicsMotor, "Mono:HDCM-Ax:HG}Mtr") # Synthetic Axis energy = Cpt(PseudoSingle, egu="KeV") - - egu = Cpt(Signal, None, add_prefix=(), value='keV', kind="config") - motor_egu = Cpt(Signal, None, add_prefix=(), value='eV', kind="config") + + egu = Cpt(Signal, None, add_prefix=(), value="keV", kind="config") + motor_egu = Cpt(Signal, None, add_prefix=(), value="eV", kind="config") ### not sure what PV should be used for the insertion device, example from SRX # u_gap = Cpt(InsertionDevice, "SR:C5-ID:G1{IVU21:1") @@ -130,19 +128,29 @@ class Energy(PseudoPositioner): # Experimental detune = Cpt(Signal, None, add_prefix=(), value=0) - def __init__(self, *args, delta_bragg:int = 0, xoffset:int = 0, C2Xcal:int = 0, T2cal:int = 0, **kwargs): + def __init__( + self, + *args, + delta_bragg: int = 0, + xoffset: int = 0, + C2Xcal: int = 0, + T2cal: int = 0, + d_111: int = 0, + **kwargs, + ): super().__init__(*args, **kwargs) self._delta_bragg = delta_bragg self._xoffset = xoffset self._C2Xcal = C2Xcal self._T2cal = T2cal + self._d_111 = d_111 self.energy.readback.name = "energy" self.energy.setpoint.name = "energy_setpoint" calib_path = Path(__file__).parent # this is temporary, we need to figure out if there is a calib file for CDI calib_file = "../data/CDIUgapCalibration.txt" - with open(calib_path / calib_file, "r") as f: + with Path.open(calib_path / calib_file) as f: next(f) uposlistIn = [] elistIn = [] @@ -152,9 +160,16 @@ def __init__(self, *args, delta_bragg:int = 0, xoffset:int = 0, C2Xcal:int = 0, if len(num) == 2: uposlistIn.append(num[0]) elistIn.append(num[1]) + self.etoulookup = make_interp_spline(elistIn, uposlistIn) + self.utoelookup = make_interp_spline(uposlistIn, elistIn) - def energy_to_positions(self, target_energy: float, undulator_harmonic: int, u_detune: float): + # can't do this until we know the insertion device + # self.u_gap.gap.user_reacback.name = self.u_gap.name + + def energy_to_positions( + self, target_energy: float, undulator_harmonic: int, u_detune: float + ): """Compute undulator and mono positions given a target energy Parameters @@ -176,12 +191,17 @@ def energy_to_positions(self, target_energy: float, undulator_harmonic: int, u_d The C2X position in millimeters ugap : float The undulator gap position in microns - """ - + """ + # Calculate Bragg RBV - bragg_RBV = np.arcsin((self.ANG_OVER_KEV / target_energy) / (2 * self.d_111)) - self._delta_bragg + bragg_RBV = ( + np.arcsin((self.ANG_OVER_KEV / target_energy) / (2 * self.d_111)) + - self._delta_bragg + ) bragg = bragg_RBV + self._delta_bragg - T2 = self._xoffset + np.sin(bragg * np.pi /180) / np.sin(2 * bragg * np.pi /180) + T2 = self._xoffset + np.sin(bragg * np.pi / 180) / np.sin( + 2 * bragg * np.pi / 180 + ) dT2 = T2 - self._T2cal C2X = self._C2Xcal - dT2 @@ -193,6 +213,14 @@ def energy_to_positions(self, target_energy: float, undulator_harmonic: int, u_d return bragg, gap, C2X, ugap + # def undulator_energy(self, harmonic: int = 3): + # ugap = self.u_gap.gap.user_readback.get() / 1000 + + # utoelookup = self.utoelookup + # cannot do thids until we know ugap for insertion device + # fundamental = float(utoelookup(ugap)) + # energy = fundamental * harmonic + @pseudo_position_argument def forward(self, p_pos): energy = p_pos.energy # energy assumed in keV @@ -205,6 +233,32 @@ def inverse(self, r_pos): e = self.ANG_OVER_KEV / (2 * self.d_111 * np.sin(bragg)) return self.PseudoPosition(energy=float(e)) + # def mono_peakup(element, acquisition_time=1.0, peakup=True): + # """ + # First draft of the mono peakup scan + # Need more info about the axis to be scanned, the move ID, and which detector will be used for feedback. + # Args: + # element (string): element name + # acquisition_time (float, optional): _description_. Defaults to 1.0. + # peakup (bool, optional): _description_. Defaults to True. + # """ + # getemissionE(element) + # energy_x = getbindingsE(element) + + # yield from mov(energy, energy_x) + # setroi(1, element) + # if peakup: + # yield from bps.sleep(5) + # yield from peakup() + # yield from xanes_plan( + # erange=[energy_x - 100, energy_x + 50], + # estep=[1.0], + # samplename=f"{element}Foil", + # filename=f"{element}Foilstd", + # acqtime=acquisition_time, + # shutter=True, + # ) + class VPM(Device): fs = DDC( From c916648827838c64ed2ec9f5aea36423c85d555d Mon Sep 17 00:00:00 2001 From: jennmald Date: Tue, 17 Feb 2026 13:10:35 -0500 Subject: [PATCH 7/9] adding complexity to the forward method, need to test at BL --- src/cditools/motors.py | 53 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/cditools/motors.py b/src/cditools/motors.py index 9770c9e..476f20e 100644 --- a/src/cditools/motors.py +++ b/src/cditools/motors.py @@ -225,7 +225,58 @@ def energy_to_positions( def forward(self, p_pos): energy = p_pos.energy # energy assumed in keV bragg, gap = self.energy_to_positions(energy) - return self.RealPosition(bragg=np.rad2deg(bragg), cgap=gap) + harmonic = self.harmonic.get() + if harmonic < 0 or ((harmonic % 2) == 0 and harmonic != 0): + raise RuntimeError( + f"The harmonic must be 0 or odd and positive, you set {harmonic}. " + "Set `energy.harmonic` to a positive odd integer or 0." + ) + detune = self.detune.get() + if energy <= self._low: + raise ValueError( + f"The energy you entered is too low ({energy} keV). " + f"Minimum energy = {self._low:.1f} keV" + ) + if energy > self._high: + if (energy < self._low * 1000) or (energy > self._high * 1000): + # Energy is invalid + raise ValueError( + f"The requested photon energy is invalid ({energy} keV). " + f"Values must be in the range of {self._low:.1f} - {self._high:.1f} keV" + ) + else: + # Energy is in eV + energy = energy / 1000.0 + + if harmonic < 3: + harmonic = 3 + # Choose the right harmonic + braggcal, c2xcal, ugapcal = self.energy_to_positions( + energy, harmonic, detune + ) + # Try higher harmonics until the required gap is too small + while True: + braggcal, c2xcal, ugapcal = self.energy_to_positions( + energy, harmonic + 2, detune + ) + if ugapcal < self.u_gap.low_limit: + break + harmonic += 2 + + self.selected_harmonic.put(harmonic) + + # Compute where we would move everything to in a perfect world + bragg, c2_x, u_gap = self.energy_to_positions(energy, harmonic, detune) + + # Sometimes move the crystal gap + if not self.move_c2_x.get(): + c2_x = self.c2_x.position + + # Sometimes move the undulator + if not self.move_u_gap.get(): + u_gap = self.u_gap.position + + return self.RealPosition(bragg=np.rad2deg(bragg), c2_x=c2_x, cgap=u_gap) @real_position_argument def inverse(self, r_pos): From aed1d6f3bd6ebf308fd8c900ae48f8eb1bf978ff Mon Sep 17 00:00:00 2001 From: jennmald Date: Tue, 17 Feb 2026 14:08:08 -0500 Subject: [PATCH 8/9] add sync with epics and retune --- src/cditools/motors.py | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/cditools/motors.py b/src/cditools/motors.py index 476f20e..0afd798 100644 --- a/src/cditools/motors.py +++ b/src/cditools/motors.py @@ -227,26 +227,21 @@ def forward(self, p_pos): bragg, gap = self.energy_to_positions(energy) harmonic = self.harmonic.get() if harmonic < 0 or ((harmonic % 2) == 0 and harmonic != 0): - raise RuntimeError( - f"The harmonic must be 0 or odd and positive, you set {harmonic}. " - "Set `energy.harmonic` to a positive odd integer or 0." - ) + msg = f"The harmonic must be 0 or odd and positive, you set {harmonic}. Set `energy.harmonic` to a positive odd integer or 0." + raise RuntimeError(msg) detune = self.detune.get() if energy <= self._low: - raise ValueError( - f"The energy you entered is too low ({energy} keV). " - f"Minimum energy = {self._low:.1f} keV" - ) + msg = f"The energy you entered is too low ({energy} keV). " + msg += f"Minimum energy = {self._low:.1f} keV" + raise ValueError(msg) if energy > self._high: if (energy < self._low * 1000) or (energy > self._high * 1000): # Energy is invalid - raise ValueError( - f"The requested photon energy is invalid ({energy} keV). " - f"Values must be in the range of {self._low:.1f} - {self._high:.1f} keV" - ) - else: - # Energy is in eV - energy = energy / 1000.0 + msg = f"The requested photon energy is invalid ({energy} keV). " + msg += f"Values must be in the range of {self._low:.1f} - {self._high:.1f} keV" + raise ValueError(msg) + # Energy is in eV + energy = energy / 1000.0 if harmonic < 3: harmonic = 3 @@ -284,6 +279,18 @@ def inverse(self, r_pos): e = self.ANG_OVER_KEV / (2 * self.d_111 * np.sin(bragg)) return self.PseudoPosition(energy=float(e)) + @pseudo_position_argument + def set(self, position): + return super().set([float(_) for _ in position]) + + def sync_with_epics(self): + self.epics_d_spacing.put(self._d_111) + self.epics_bragg_offset.put(self._delta_bragg) + + def retune_undulator(self): + self.detune.put(0.0) + self.move(self.engergy.get()[0]) + # def mono_peakup(element, acquisition_time=1.0, peakup=True): # """ # First draft of the mono peakup scan From fe2392e8c1b8300bb5f9a8b4df40a4c7d3cd26e2 Mon Sep 17 00:00:00 2001 From: jennmald Date: Wed, 18 Feb 2026 14:04:38 -0500 Subject: [PATCH 9/9] typing and linting --- src/cditools/motors.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/cditools/motors.py b/src/cditools/motors.py index 0afd798..5cf9f22 100644 --- a/src/cditools/motors.py +++ b/src/cditools/motors.py @@ -21,26 +21,26 @@ class EpicsMotorRO(EpicsMotor): - def __init__(self, *args, **kwargs): + def __init__(self, *args: object, **kwargs: object): super().__init__(*args, **kwargs) - def move(self, *args, **kwargs): # noqa: ARG002 + def move(self, *args: object, **kwargs: object): # noqa: ARG002 msg = f"{self.name} is read-only and cannot be moved." raise PermissionError(msg) - def stop(self, *args, **kwargs): # noqa: ARG002 + def stop(self, *args: object, **kwargs: object): # noqa: ARG002 msg = f"{self.name} is read-only and cannot be stopped manually." raise PermissionError(msg) - def set(self, *args, **kwargs): # noqa: ARG002 + def set(self, *args: object, **kwargs: object): # noqa: ARG002 msg = f"{self.name} is read-only and cannot be set." raise PermissionError(msg) - def set_position(self, *args, **kwargs): # noqa: ARG002 + def set_position(self, *args: object, **kwargs: object): # noqa: ARG002 msg = f"{self.name} is read-only and its position cannot be set." raise PermissionError(msg) - def _readonly_put(self, *args, **kwargs): # noqa: ARG002 + def _readonly_put(self, *args: object, **kwargs: object): # noqa: ARG002 msg = f"{self.name} is read-only and cannot write PVs." raise PermissionError(msg) @@ -85,7 +85,7 @@ class DMM(Device): class DCMBase(Device): pitch = Cpt(EpicsMotor, "Mono:HDCM-Ax:Pitch}Mtr") - fine: ClassVar[dict] = { + fine: ClassVar[dict[str, Cpt]] = { "fpitch": Cpt(EpicsMotor, "Mono:HDCM-Ax:FP}Mtr"), "roll": Cpt(EpicsMotor, "Mono:HDCM-Ax:Roll}Mtr"), } @@ -130,13 +130,13 @@ class Energy(PseudoPositioner): def __init__( self, - *args, + *args: object, delta_bragg: int = 0, xoffset: int = 0, C2Xcal: int = 0, T2cal: int = 0, d_111: int = 0, - **kwargs, + **kwargs: object, ): super().__init__(*args, **kwargs) self._delta_bragg = delta_bragg @@ -168,7 +168,10 @@ def __init__( # self.u_gap.gap.user_reacback.name = self.u_gap.name def energy_to_positions( - self, target_energy: float, undulator_harmonic: int, u_detune: float + self, + target_energy: float, + undulator_harmonic: int = 0, + u_detune: float = 0.0, ): """Compute undulator and mono positions given a target energy @@ -222,9 +225,9 @@ def energy_to_positions( # energy = fundamental * harmonic @pseudo_position_argument - def forward(self, p_pos): + def forward(self, p_pos: object): energy = p_pos.energy # energy assumed in keV - bragg, gap = self.energy_to_positions(energy) + bragg, gap, _, _ = self.energy_to_positions(energy) harmonic = self.harmonic.get() if harmonic < 0 or ((harmonic % 2) == 0 and harmonic != 0): msg = f"The harmonic must be 0 or odd and positive, you set {harmonic}. Set `energy.harmonic` to a positive odd integer or 0." @@ -274,13 +277,13 @@ def forward(self, p_pos): return self.RealPosition(bragg=np.rad2deg(bragg), c2_x=c2_x, cgap=u_gap) @real_position_argument - def inverse(self, r_pos): + def inverse(self, r_pos: object): bragg = np.deg2rad(r_pos.bragg) e = self.ANG_OVER_KEV / (2 * self.d_111 * np.sin(bragg)) return self.PseudoPosition(energy=float(e)) @pseudo_position_argument - def set(self, position): + def set(self, position: list[int | float]): return super().set([float(_) for _ in position]) def sync_with_epics(self):