diff --git a/src/firefly/asymmotron.py b/src/firefly/asymmotron.py new file mode 100644 index 00000000..2050df2e --- /dev/null +++ b/src/firefly/asymmotron.py @@ -0,0 +1,44 @@ +import logging +import warnings + +import haven +from firefly import display + +# from haven.instrument import analyzer + +log = logging.getLogger(__name__) + + +class SlitsDisplay(display.FireflyDisplay): + + def customize_device(self): + self.device = haven.registry.find(self.macros()["DEVICE"]) + + def ui_filename(self): + return "asymmotron.ui" + + +# ----------------------------------------------------------------------------- +# :author: Mark Wolfman +# :email: wolfman@anl.gov +# :copyright: Copyright © 2023, UChicago Argonne, LLC +# +# Distributed under the terms of the 3-Clause BSD License +# +# The full license is in the file LICENSE, distributed with this software. +# +# DISCLAIMER +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# ----------------------------------------------------------------------------- diff --git a/src/firefly/asymmotron.ui b/src/firefly/asymmotron.ui new file mode 100644 index 00000000..6645c018 --- /dev/null +++ b/src/firefly/asymmotron.ui @@ -0,0 +1,810 @@ + + + Form + + + + 0 + 0 + 951 + 885 + + + + + Sans Serif + + + + ${DEVICE_TITLE} + + + + + 0 + 0 + 951 + 891 + + + + <html><head/><body><p><img src=":/Asymmotron/asymmotron.png"/></p></body></html> + + + + + + -40 + 0 + 241 + 41 + + + + + 16 + + + + + + + <html><head/><body><p><span style=" font-weight:600;">${DEVICE_TITLE}</span></p></body></html> + + + Qt::RichText + + + Qt::AlignRight|Qt::AlignTop|Qt::AlignTrailing + + + + + + 820 + 20 + 119 + 111 + + + + + + + QFrame::NoFrame + + + {"TITLE": "Detector Vert", "AXIS": "${DEVICE}.h.center"} + + + slits_motor.py + + + true + + + true + + + false + + + + + + 20 + 160 + 119 + 111 + + + + + + + QFrame::NoFrame + + + {"TITLE": "Crystal Vert", "AXIS": "${DEVICE}.vert"} + + + slits_motor.py + + + true + + + true + + + false + + + + + + 820 + 260 + 119 + 111 + + + + + + + QFrame::NoFrame + + + {"TITLE": "Detector X", "AXIS": "${DEVICE}.h.center"} + + + slits_motor.py + + + true + + + true + + + false + + + + + + 20 + 390 + 131 + 291 + + + + 0 + + + + + 0 + 0 + 131 + 136 + + + + Crystal 1 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Asymmetry angle", "AXIS": "${DEVICE}.analyzers.analyzer1.alpha", "": ""} + + + slits_motor.py + + + false + + + true + + + false + + + + + + + + + 0 + 0 + 131 + 136 + + + + Crystal 2 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Asymmetry angle", "AXIS": "${DEVICE}.h.size", "": ""} + + + slits_motor.py + + + false + + + true + + + false + + + + + + + + + 0 + 0 + 131 + 136 + + + + Crystal 3 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Asymmetry angle", "AXIS": "${DEVICE}.h.size", "": ""} + + + slits_motor.py + + + false + + + true + + + false + + + + + + + + + 0 + 0 + 131 + 136 + + + + Crystal 4 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Asymmetry angle", "AXIS": "${DEVICE}.h.size", "": ""} + + + slits_motor.py + + + false + + + true + + + false + + + + + + + + + 0 + 0 + 131 + 136 + + + + Crystal 5 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Asymmetry angle", "AXIS": "${DEVICE}.h.size", "": ""} + + + slits_motor.py + + + false + + + true + + + false + + + + + + + + + + 350 + 380 + 131 + 291 + + + + 4 + + + + + 0 + 0 + 131 + 136 + + + + Crystal 1 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Crystal X", "AXIS": "${DEVICE}.analyzers.analyzer1.x"} + + + asymmotron_table_motor.py + + + true + + + true + + + false + + + + + + + + + 0 + 0 + 131 + 136 + + + + Crystal 2 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Crystal X", "AXIS": "${DEVICE}.analyzers.analyzer2.x"} + + + asymmotron_table_motor.py + + + true + + + true + + + false + + + + + + + + + 0 + 0 + 131 + 136 + + + + Crystal 3 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Crystal X", "AXIS": "${DEVICE}.analyzers.analyzer3.x"} + + + asymmotron_table_motor.py + + + true + + + true + + + false + + + + + + + + + 0 + 0 + 131 + 136 + + + + Crystal 4 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Crystal X", "AXIS": "${DEVICE}.analyzers.analyzer4.x"} + + + asymmotron_table_motor.py + + + true + + + true + + + false + + + + + + + + + 0 + 0 + 131 + 136 + + + + Crystal 5 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Crystal X", "AXIS": "${DEVICE}.analyzers.analyzer5.x"} + + + asymmotron_table_motor.py + + + true + + + true + + + false + + + + + + + + + + 790 + 570 + 131 + 291 + + + + 0 + + + + + 0 + 0 + 131 + 136 + + + + Crystal 1 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Energy", "AXIS": "${DEVICE}.v.size"} + + + asymmotron_table_motor.py + + + true + + + true + + + false + + + + + + + + + 0 + 0 + 131 + 136 + + + + Crystal 2 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Energy", "AXIS": "${DEVICE}.analyzers.analyzer2.energy"} + + + asymmotron_table_motor.py + + + true + + + true + + + false + + + + + + + + + 0 + 0 + 131 + 136 + + + + Crystal 3 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Energy", "AXIS": "${DEVICE}.analyzers.analyzer3.energy"} + + + asymmotron_table_motor.py + + + true + + + true + + + false + + + + + + + + + 0 + 0 + 131 + 136 + + + + Crystal 4 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Energy", "AXIS": "${DEVICE}.analyzers.analyzer4.energy"} + + + asymmotron_table_motor.py + + + true + + + true + + + false + + + + + + + + + 0 + 0 + 131 + 136 + + + + Crystal 5 + + + + + + + + + QFrame::NoFrame + + + {"TITLE": "Energy", "AXIS": "${DEVICE}.analyzers.analyzer5.energy"} + + + asymmotron_table_motor.py + + + true + + + true + + + false + + + + + + + + + + PyDMLabel + QLabel +
pydm.widgets.label
+
+ + PyDMEmbeddedDisplay + QFrame +
pydm.widgets.embedded_display
+
+
+ + + + +
diff --git a/src/firefly/resources/asymmotron.png b/src/firefly/resources/asymmotron.png new file mode 100644 index 00000000..1f0b8873 Binary files /dev/null and b/src/firefly/resources/asymmotron.png differ diff --git a/src/firefly/resources/beamline_components.qrc b/src/firefly/resources/beamline_components.qrc index e0348098..1ed638f2 100644 --- a/src/firefly/resources/beamline_components.qrc +++ b/src/firefly/resources/beamline_components.qrc @@ -8,6 +8,9 @@ slits_3d.png filter.png + + asymmotron.png + insertion_device.png diff --git a/src/haven/asymmotron_export.py b/src/haven/asymmotron_export.py new file mode 100644 index 00000000..e69de29b diff --git a/src/haven/catalog.py b/src/haven/catalog.py index 959ae8d7..41cb4fa4 100644 --- a/src/haven/catalog.py +++ b/src/haven/catalog.py @@ -179,11 +179,12 @@ def tiled_client( if cache_filepath is None: cache_filepath = config["database"].get("tiled", {}).get("cache_filepath", "") cache_filepath = cache_filepath or None - if os.access(cache_filepath, os.W_OK): + if cache_filepath is None: + cache = None + elif os.access(cache_filepath, os.W_OK): cache = ThreadSafeCache(filepath=cache_filepath) else: warnings.warn(f"Cache file is not writable: {cache_filepath}") - cache = None # Create the client if uri is None: uri = config["database"]["tiled"]["uri"] diff --git a/src/haven/devices/area_detector.py b/src/haven/devices/area_detector.py index 95045745..8b99b3db 100644 --- a/src/haven/devices/area_detector.py +++ b/src/haven/devices/area_detector.py @@ -323,10 +323,14 @@ def __init__( super().__init__(*args, write_path_template=write_path_template, **kwargs) # Format the file_write_template with per-device values config = load_config() + root_path = config.get("area_detector_root_path", "tmp") + # Remove the leading slash for some reason...makes ophyd happy + root_path = root_path.lstrip("/") try: + self.write_path_template = self.write_path_template.format( name=self.parent.name, - root_path=config.get("area_detector_root_path", "tmp"), + root_path=root_path, ) except KeyError: warnings.warn(f"Could not format write_path_template {write_path_template}") @@ -483,8 +487,8 @@ class Eiger500K(SingleTrigger, DetectorBase): cam = ADCpt(EigerCam, "cam1:") image = ADCpt(ImagePlugin_V34, "image1:") pva = ADCpt(PvaPlugin_V34, "Pva1:") - tiff = ADCpt(TIFFPlugin, "TIFF1:", kind=Kind.normal) - hdf1 = ADCpt(HDF5Plugin, "HDF1:", kind=Kind.normal) + # tiff = ADCpt(TIFFPlugin, "TIFF1:", kind=Kind.normal) + hdf = ADCpt(HDF5FilePlugin, "HDF1:", kind=Kind.normal) roi1 = ADCpt(ROIPlugin_V34, "ROI1:", kind=Kind.config) roi2 = ADCpt(ROIPlugin_V34, "ROI2:", kind=Kind.config) roi3 = ADCpt(ROIPlugin_V34, "ROI3:", kind=Kind.config) @@ -501,8 +505,8 @@ class Eiger500K(SingleTrigger, DetectorBase): "stats3", "stats4", "stats5", - "hdf1", - "tiff", + "hdf", + # "tiff", ] diff --git a/src/haven/devices/asymmotron.py b/src/haven/devices/asymmotron.py new file mode 100644 index 00000000..596e2e27 --- /dev/null +++ b/src/haven/devices/asymmotron.py @@ -0,0 +1,399 @@ +"""More calculations from Yanna. + +def theta_bragg(energy_val, hkl=[4,4,4]): + d_spacing = lattice_cons / np.sqrt(hkl[0] ** 2 + hkl[1] ** 2 + hkl[2] ** 2) + theta_bragg_val = np.arcsin(hc / (energy_val * 2 * d_spacing)) + theta_bragg_val = np.degrees(theta_bragg_val) + return theta_bragg_val + + +Some sane values for converting hkl and [HKL] to α: + +(001), (101), 90° +(001), (110), 180° + +""" + +import asyncio +import logging + +import numpy as np +from bluesky.protocols import Movable +from ophyd import Component as Cpt +from ophyd import Device, EpicsMotor +from ophyd import FormattedComponent as FCpt +from ophyd import PseudoPositioner, PseudoSingle, Signal +from ophyd.pseudopos import pseudo_position_argument, real_position_argument +from ophyd_async.core import ( + AsyncStatus, + ConfigSignal, + Device, + StandardReadable, + soft_signal_r_and_setter, + soft_signal_rw, +) +from scipy import constants + +from ..positioner import Positioner +from .motor import Motor +from .signal import derived_signal_r, derived_signal_rw + +log = logging.getLogger(__name__) + +um_per_mm = 1000 + + +h = constants.physical_constants["Planck constant in eV/Hz"][0] +c = constants.c + + +def energy_to_wavelength(energy): + """Energy in eV to wavelength in meters.""" + return h * c / energy + + +wavelength_to_energy = energy_to_wavelength + + +def bragg_to_wavelength(bragg_angle: float, d: float, n: int = 1): + """Convert Bragg angle to wavelength. + + Parameters + ========== + bragg_angle + The Bragg angle (θ) of the reflection. + d + Inter-planar spacing of the crystal. + n + The order of the reflection. + """ + return 2 * d * np.sin(bragg_angle) / n + + +def wavelength_to_bragg(wavelength: float, d: float, n: int = 1): + """Convert wavelength to Bragg angle. + + Parameters + ========== + wavelength + The photon wavelength in meters. + d + Inter-planar spacing of the crystal. + n + The order of the reflection. + + Returns + ======= + bragg + The Bragg angle of the reflection, in Radians. + """ + return np.arcsin(n * wavelength / 2 / d) + + +def energy_to_bragg(energy: float, d: float) -> float: + """Convert photon energy to Bragg angle. + + Parameters + ========== + energy + Photon energy, in eV. + d + d-spacing of the analyzer crystal, in meters. + + Returns + ======= + bragg + First order Bragg angle for this photon, in radians. + + """ + bragg = np.arcsin(h * c / 2 / d / energy) + return bragg + + +def bragg_to_energy(bragg: float, d: float) -> float: + """Convert Bragg angle to photon energy. + + Parameters + ========== + bragg + Bragg angle for the crystal, in radians. + d + d-spacing of the analyzer crystal, in meters. + + Returns + ======= + energy + Photon energy, in eV. + + """ + energy = h * c / 2 / d / np.sin(bragg) + return energy + + +def hkl_to_alpha(base, reflection): + cos_alpha = ( + np.dot(base, reflection) / np.linalg.norm(base) / np.linalg.norm(reflection) + ) + if cos_alpha > 1: + cos_alpha = 1 + alpha = np.arccos(cos_alpha) + return alpha + + +class HKL(StandardReadable, Movable): + """A set of (h, k, l) for a lattice plane. + + Settable as ``hkl.set('312')``, which will set ``hkl.h``, + ``hkl.k``, and ``hkl.l``. + + """ + + def __init__(self, initial_value, name=""): + h, k, l = self._to_tuple(initial_value) + self.h = soft_signal_rw(int, initial_value=h) + self.k = soft_signal_rw(int, initial_value=k) + self.l = soft_signal_rw(int, initial_value=l) + + super().__init__(name=name) + + def _to_tuple(self, hkl_str): + h, k, l = hkl_str + return (h, k, l) + + @AsyncStatus.wrap + async def set(self, value): + h, k, l = self._to_tuple(value) + await asyncio.gather( + self.h.set(h), + self.k.set(k), + self.l.set(l), + ) + + +class Analyzer(StandardReadable): + """A single asymmetric analyzer crystal mounted on an Rowland circle. + + Linear dimensions (e.g. Rowland diameter) should be in units that + match those of the real motors. Angles are in radians. + + """ + + linear_units = "mm" + angular_units = "rad" + + def __init__( + self, + *, + horizontal_motor_prefix: str, + vertical_motor_prefix: str, + yaw_motor_prefix: str, + rowland_diameter: float | int = 500000, # µm + lattice_constant: float = 0.543095e-9, # m + wedge_angle: float = np.radians(30), + surface_plane: tuple[int, int, int] | str = "211", + name: str = "", + ): + surface_plane = tuple(int(i) for i in surface_plane) + # Create the real motors + self.horizontal = Motor(horizontal_motor_prefix) + self.vertical = Motor(vertical_motor_prefix) + self.crystal_yaw = Motor(yaw_motor_prefix) + # Reciprocal space geometry + self.reflection = HKL(initial_value="111") + self.surface_plane = HKL(initial_value=surface_plane) + self.add_readables( + [ + self.reflection.h, + self.reflection.k, + self.reflection.l, + self.surface_plane.h, + self.surface_plane.k, + self.surface_plane.l, + ], + ConfigSignal, + ) + # Soft signals for keeping track of the fixed transform properties + with self.add_children_as_readables(ConfigSignal): + self.rowland_diameter = soft_signal_rw( + float, units=self.linear_units, initial_value=rowland_diameter + ) + self.wedge_angle = soft_signal_rw( + float, units=self.angular_units, initial_value=wedge_angle + ) + self.lattice_constant = soft_signal_rw( + float, units=self.linear_units, initial_value=lattice_constant + ) + self.bragg_offset = soft_signal_rw(float, units=self.linear_units) + # Soft signals for intermediate, calculated values + self.d_spacing = derived_signal_r( + float, + derived_from={ + "H": self.reflection.h, + "K": self.reflection.k, + "L": self.reflection.l, + "a": self.lattice_constant, + }, + inverse=self._calc_d_spacing, + units=self.linear_units, + precision=4, + ) + self.asymmetry_angle = derived_signal_r( + float, + derived_from={ + "H": self.reflection.h, + "K": self.reflection.k, + "L": self.reflection.l, + "h": self.surface_plane.h, + "k": self.surface_plane.k, + "l": self.surface_plane.l, + }, + units=self.angular_units, + inverse=self._calc_alpha, + ) + # The actual energy signal that controls the analyzer + self.energy = EnergyPositioner(xtal=self) + # Decide which signals should be readable/config/etc + self.add_readables( + [ + self.energy.readback, + self.energy.setpoint, + self.vertical.user_readback, + self.horizontal.user_readback, + ] + ) + self.add_readables( + [ + self.crystal_yaw.user_readback, + ], + ConfigSignal, + ) + super().__init__(name=name) + + def _calc_alpha(self, values, H, K, L, h, k, l): + """Calculate the asymmetry angle for a given reflection and base plane. + + Parameters + ========== + H, K, L + The specific reflection plane to use. + h, k, l + The base cut of the crystal surface. + + """ + base = (values[h], values[k], values[l]) + refl = (values[H], values[K], values[L]) + return hkl_to_alpha(base=base, reflection=refl) + + def _calc_d_spacing(self, values, H, K, L, a): + hkl = (values[H], values[K], values[L]) + return values[a] / np.linalg.norm(hkl) + + +class EnergyPositioner(Positioner): + """Positions the energy of an analyzer crystal.""" + + def __init__(self, *, xtal: Analyzer, name: str = ""): + xtal_signals = { + "D": xtal.rowland_diameter, + "d": xtal.d_spacing, + "beta": xtal.wedge_angle, + "alpha": xtal.asymmetry_angle, + } + self.setpoint = derived_signal_rw( + float, + units="eV", + derived_from=dict( + x=xtal.horizontal.user_setpoint, + y=xtal.vertical.user_setpoint, + **xtal_signals, + ), + forward=self.forward, + inverse=self.inverse, + ) + with self.add_children_as_readables(): + self.readback = derived_signal_r( + float, + units="eV", + derived_from=dict( + x=xtal.horizontal.user_readback, + y=xtal.vertical.user_readback, + **xtal_signals, + ), + inverse=self.inverse, + ) + # Metadata + self.velocity, _ = soft_signal_r_and_setter(float, initial_value=0.001) + self.units, _ = soft_signal_r_and_setter(str, initial_value="eV") + self.precision, _ = soft_signal_r_and_setter(int, initial_value=3) + super().__init__(name=name, put_complete=True) + + async def forward(self, value, D, d, beta, alpha, x, y): + """Run a forward (pseudo -> real) calculation""" + # Resolve the dependent signals into their values + energy = value + D, d, beta, alpha = await asyncio.gather( + D.get_value(), + d.get_value(), + beta.get_value(), + alpha.get_value(), + ) + # Step 0: convert energy to bragg angle + bragg = energy_to_bragg(energy, d=d) + # Step 1: Convert energy params to geometry params + theta_M = bragg + alpha + rho = D * np.sin(theta_M) + # Step 2: Convert geometry params to motor positions + y_val = rho * np.cos(theta_M) / np.cos(beta) + x_val = -y_val * np.sin(beta) + rho * np.sin(theta_M) + # Report the calculated result + return { + x: x_val, + y: y_val, + } + + def inverse(self, values, D, d, beta, alpha, x, y): + """Run an inverse (real -> pseudo) calculation""" + # Resolve signals into their values + x = values[x] + y = values[y] + D = values[D] + d = values[d] + beta = values[beta] + alpha = values[alpha] + # Step 1: Convert motor positions to geometry parameters + theta_M = np.arctan2((x + y * np.sin(beta)), (y * np.cos(beta))) + rho = y * np.cos(beta) / np.cos(theta_M) + # Step 1: Convert geometry params to energy + bragg = theta_M - alpha + energy = bragg_to_energy(bragg, d=d) + return energy + + +class Asymmotron(StandardReadable): + pass + + +# ----------------------------------------------------------------------------- +# :author: Mark Wolfman +# :email: wolfman@anl.gov +# :copyright: Copyright © 2023, UChicago Argonne, LLC +# +# Distributed under the terms of the 3-Clause BSD License +# +# The full license is in the file LICENSE, distributed with this software. +# +# DISCLAIMER +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# ----------------------------------------------------------------------------- diff --git a/src/haven/devices/energy_positioner.py b/src/haven/devices/energy_positioner.py index 6e1dd529..c86289af 100644 --- a/src/haven/devices/energy_positioner.py +++ b/src/haven/devices/energy_positioner.py @@ -65,19 +65,18 @@ def __init__( undulator_prefix: str, name: str = "energy", ): - with self.add_children_as_readables(): - self.monochromator = Monochromator(monochromator_prefix) - self.undulator = PlanarUndulator(undulator_prefix) - # Derived positioner signals - self.setpoint = derived_signal_rw( - float, - derived_from={ - "mono": self.monochromator.energy.user_setpoint, - "undulator": self.undulator.energy.setpoint, - }, - forward=self.set_energy, - inverse=self.get_energy, - ) + self.monochromator = Monochromator(monochromator_prefix) + self.undulator = PlanarUndulator(undulator_prefix) + # Derived positioner signals + self.setpoint = derived_signal_rw( + float, + derived_from={ + "mono": self.monochromator.energy.user_setpoint, + "undulator": self.undulator.energy.setpoint, + }, + forward=self.set_energy, + inverse=self.get_energy, + ) with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL): self.readback = derived_signal_r( float, diff --git a/src/haven/devices/lerix.py b/src/haven/devices/lerix.py deleted file mode 100644 index 1829d4bf..00000000 --- a/src/haven/devices/lerix.py +++ /dev/null @@ -1,227 +0,0 @@ -import logging - -import numpy as np -from ophyd import Component as Cpt -from ophyd import Device, EpicsMotor -from ophyd import FormattedComponent as FCpt -from ophyd import PseudoPositioner, PseudoSingle -from ophyd.pseudopos import pseudo_position_argument, real_position_argument - -log = logging.getLogger(__name__) - -um_per_mm = 1000 - - -class RowlandPositioner(PseudoPositioner): - """A pseudo positioner describing a rowland circle. - - Real Axes - ========= - x - y - z - z1 - - Pseudo Axes - =========== - D - In mm - theta - In degrees - alpha - In degrees - """ - - def __init__( - self, - x_motor_pv: str, - y_motor_pv: str, - z_motor_pv: str, - z1_motor_pv: str, - *args, - **kwargs, - ): - self.x_motor_pv = x_motor_pv - self.y_motor_pv = y_motor_pv - self.z_motor_pv = z_motor_pv - self.z1_motor_pv = z1_motor_pv - super().__init__(*args, **kwargs) - - # Pseudo axes - D: PseudoSingle = Cpt(PseudoSingle, name="D", limits=(0, 1000)) - theta: PseudoSingle = Cpt(PseudoSingle, name="theta", limits=(0, 180)) - alpha: PseudoSingle = Cpt(PseudoSingle, name="alpha", limits=(0, 180)) - - # Real axes - x: EpicsMotor = FCpt(EpicsMotor, "{x_motor_pv}", name="x") - y: EpicsMotor = FCpt(EpicsMotor, "{y_motor_pv}", name="y") - z: EpicsMotor = FCpt(EpicsMotor, "{z_motor_pv}", name="z") - z1: EpicsMotor = FCpt(EpicsMotor, "{z1_motor_pv}", name="z1") - - @pseudo_position_argument - def forward(self, pseudo_pos): - """Run a forward (pseudo -> real) calculation""" - # Convert distance to microns and degrees to radians - D = pseudo_pos.D * um_per_mm - theta = pseudo_pos.theta / 180.0 * np.pi - alpha = pseudo_pos.alpha / 180.0 * np.pi - # Convert virtual positions to real positions - x = D * (np.sin(theta + alpha)) ** 2 - y = D * ((np.sin(theta + alpha)) ** 2 - (np.sin(theta - alpha)) ** 2) - z1 = D * np.sin(theta - alpha) * np.cos(theta + alpha) - z2 = D * np.sin(theta - alpha) * np.cos(theta - alpha) - z = z1 + z2 - print(x, y, z1, z) - return self.RealPosition( - x=x, - y=y, - z=z, - z1=z1, - ) - - @real_position_argument - def inverse(self, real_pos): - """Run an inverse (real -> pseudo) calculation""" - return self.PseudoPosition(D=0, theta=0, alpha=0) - # Expand the variables - x = real_pos.x - y = real_pos.y - z = real_pos.z - z1 = real_pos.z1 - # Invert the calculation, first for 'd' - a = y - b = -x - c = z1**2 - d = -(z1**2 * y) # Maybe needs parens? - p = (3 * a * c - b**2) / (3 * a**2) - q = (2 * b**3 - 9 * a * b * c + 27 * a**2 * d) / (27 * a**3) - D = (-(q / 2) + (q**2 / 4 + p**3 / 27) ** 0.5) ** (1 / 3) + ( - -(q / 2) - (q**2 / 4 + p**3 / 27) ** 0.5 - ) ** (1 / 3) - # D = x / ((1 - (z1 ** 2 / (D ** 2 * y + z1 ** 2))) ** 2) - # cos(theta + alpha) = (z1 ** 2 / (D ** 2 * y + z1 ** 2)) ** 0.5 - print(a, b, c, d) - print(p, q, D) - return self.PseudoPosition(D=D, theta=0, alpha=0) - - -# Rewrite the following four equations so that they calculate D, theta and alpha based on inputting x, y, z1 and z. - -# x = D * (sin(theta + alpha)) ** 2 -# y = D * ((sin(theta + alpha)) ** 2 - (sin(theta - alpha)) ** 2) -# z1 = D * sin(theta - alpha) * cos(theta + alpha) -# z = z1 + D * sin(theta - alpha) * cos(theta - alpha) - -# To calculate D, theta, and alpha based on x, y, z1, and z, we can rearrange the given equations as follows: - -# D = x / (sin(theta + alpha)) ** 2 - -# sin(theta - alpha) = z1 / (D * cos(theta + alpha)) - -# cos(theta - alpha) = (z - z1) / (D * sin(theta - alpha)) - -# Substitute the above two equations into the expression for y: - -# y = D * ((sin(theta + alpha)) ** 2 - (sin(theta - alpha)) ** 2) - -# y = D * ((sin(theta + alpha)) ** 2 - (z1 / (D * cos(theta + alpha))) ** 2) - -# y = D * ((sin(theta + alpha)) ** 2 - (z1 ** 2 / D ** 2) * (cos(theta + alpha)) ** (-2)) - -# Solve for cos(theta + alpha) in the above equation: - -# cos(theta + alpha) = (z1 ** 2 / (D ** 2 * y + z1 ** 2)) ** 0.5 - -# Substitute the value of cos(theta + alpha) into the equation for sin(theta - alpha): - -# sin(theta - alpha) = z1 / (D * (z1 ** 2 / (D ** 2 * y + z1 ** 2)) ** 0.5) - -# Simplify the above equation: - -# sin(theta - alpha) = (z1 ** 2 / (D ** 2 * y + z1 ** 2)) ** 0.5 - -# Finally, substitute the values of cos(theta + alpha) and sin(theta - alpha) into the equation for D: - -# D = x / ((sin(theta + alpha)) ** 2) - -# D = x / ((1 - (z1 ** 2 / (D ** 2 * y + z1 ** 2))) ** 2) - -# This equation can be solved numerically to obtain the value of D. Once D is known, we can use the equations for cos(theta + alpha) and sin(theta - alpha) to calculate theta and alpha. - - -class LERIXSpectrometer(Device): - rowland = Cpt( - RowlandPositioner, - x_motor_pv="vme_crate_ioc:m1", - y_motor_pv="vme_crate_ioc:m2", - z_motor_pv="vme_crate_ioc:m3", - z1_motor_pv="vme_crate_ioc:m4", - name="rowland", - ) - - -# async def make_lerix_device(name: str, x_pv: str, y_pv: str, z_pv: str, z1_pv: str): -# dev = RowlandPositioner( -# name=name, -# x_motor_pv=x_pv, -# y_motor_pv=y_pv, -# z_motor_pv=z_pv, -# z1_motor_pv=z1_pv, -# labels={"lerix_spectrometers"}, -# ) -# pvs = ", ".join((x_pv, y_pv, z_pv, z1_pv)) -# try: -# await await_for_connection(dev) -# except TimeoutError as exc: -# log.warning(f"Could not connect to LERIX spectrometer: {name} ({pvs})") -# else: -# log.info(f"Created area detector: {name} ({pvs})") -# return dev - - -# def load_lerix_spectrometers(config=None): -# """Create devices for the LERIX spectrometer.""" -# if config is None: -# config = load_config() -# # Create spectrometers -# devices = [] -# for name, cfg in config.get("lerix", {}).items(): -# rowland = cfg["rowland"] -# devices.append( -# make_device( -# RowlandPositioner, -# name=name, -# x_motor_pv=rowland["x_motor_pv"], -# y_motor_pv=rowland["y_motor_pv"], -# z_motor_pv=rowland["z_motor_pv"], -# z1_motor_pv=rowland["z1_motor_pv"], -# labels={"lerix_spectromoters"}, -# ) -# ) -# return devices - - -# ----------------------------------------------------------------------------- -# :author: Mark Wolfman -# :email: wolfman@anl.gov -# :copyright: Copyright © 2023, UChicago Argonne, LLC -# -# Distributed under the terms of the 3-Clause BSD License -# -# The full license is in the file LICENSE, distributed with this software. -# -# DISCLAIMER -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# ----------------------------------------------------------------------------- diff --git a/src/haven/devices/xray_source.py b/src/haven/devices/xray_source.py index 5f292dd9..39b3f4d9 100644 --- a/src/haven/devices/xray_source.py +++ b/src/haven/devices/xray_source.py @@ -115,7 +115,7 @@ def __init__(self, prefix: str, name: str = ""): actuate_signal=self.start_button, stop_signal=self.stop_button, done_signal=self.busy, - min_move=0.010, + min_move=0.0, ) self.energy_taper = UndulatorPositioner( prefix=f"{prefix}TaperEnergy", @@ -128,7 +128,7 @@ def __init__(self, prefix: str, name: str = ""): actuate_signal=self.start_button, stop_signal=self.stop_button, done_signal=self.busy, - min_move=0.004, + min_move=0.0, ) self.gap_taper = UndulatorPositioner( prefix=f"{prefix}TaperGap", diff --git a/src/haven/iconfig_testing.toml b/src/haven/iconfig_testing.toml index 4caa94f1..de12658e 100644 --- a/src/haven/iconfig_testing.toml +++ b/src/haven/iconfig_testing.toml @@ -302,6 +302,11 @@ num_elements = 4 name = "filter_bank0" prefix = "255idc:pfcu0:" +[[ filter_bank0 ]] +class = "pfcu4" +prefix = "255idc:pfcu0:" + + [[ pfcu4 ]] name = "filter_bank1" prefix = "255idc:pfcu1:" diff --git a/src/haven/instrument.py b/src/haven/instrument.py index fea42894..a6cbaa92 100644 --- a/src/haven/instrument.py +++ b/src/haven/instrument.py @@ -17,6 +17,7 @@ from .devices.aerotech import AerotechStage from .devices.aps import ApsMachine from .devices.area_detector import make_area_detector +from .devices.asymmotron import Asymmotron, Analyzer from .devices.beamline_manager import BeamlineManager from .devices.detectors.aravis import AravisDetector from .devices.detectors.sim_detector import SimDetector @@ -414,6 +415,8 @@ async def load( "sim_detector": SimDetector, "camera": AravisDetector, "pss_shutter": PssShutter, + "asymmotron": Asymmotron, + "analyzer": Analyzer, # Threaded ophyd devices "blade_slits": BladeSlits, "aperture_slits": ApertureSlits, diff --git a/src/haven/ipython_startup.ipy b/src/haven/ipython_startup.ipy index 95b15271..60ef540d 100644 --- a/src/haven/ipython_startup.ipy +++ b/src/haven/ipython_startup.ipy @@ -2,6 +2,7 @@ import asyncio import logging import time +import numpy as np import databroker # noqa: F401 import matplotlib.pyplot as plt # noqa: F401 from bluesky import plan_stubs as bps # noqa: F401 @@ -22,6 +23,7 @@ from rich.panel import Panel from rich.theme import Theme import haven # noqa: F401 +from haven.xdi_export import XDIWriter logging.basicConfig(level=logging.WARNING) @@ -30,8 +32,8 @@ log = logging.getLogger(__name__) # Create a run engine RE = haven.run_engine( connect_databroker=True, - call_returns_result=True, - use_bec=True, + call_returns_result=False, + use_bec=False, ) # Add metadata to the run engine @@ -66,10 +68,26 @@ try: ion_chambers = registry.findall("ion_chambers", allow_none=True) except ComponentNotFound as exc: log.exception(exc) -try: - energy = registry['energy'] -except ComponentNotFound as exc: - log.exception(exc) +for cpt in registry._objects_by_name.values(): + # Replace spaces and other illegal characters in variable name + # name = re.sub('\W|^(?=\d)','_', cpt.name) + name = haven.sanitize_name(cpt.name) + # Add the device as a variable in module's globals + globals().setdefault(name, cpt) + +# Default tiled client +client = haven.tiled_client() + +# Extra startup script for Jerry's beamtime +import pyqtgraph as pg +def view_images(uid): + run = client[uid] + data = run['primary/data'] + im = np.sum(data['eiger_image'].read().compute(), axis=1) + return pg.image(im) +writer = XDIWriter("/net/s25data/export/25-ID-D/commissioning/2024-11-13-asymmotron/{day}{hour}{minute}_{sample_name}_{scan_id}.csv", metadata_keys=["purpose"], sep=",") +# RE.subscribe(writer) +detectors = [eiger, I0, IpreKB, analyzer0, analyzer1, analyzer2, analyzer3] # Print helpful information to the console custom_theme = Theme( diff --git a/src/haven/run_engine.py b/src/haven/run_engine.py index 88b537c8..28498e35 100644 --- a/src/haven/run_engine.py +++ b/src/haven/run_engine.py @@ -9,6 +9,7 @@ from .exceptions import ComponentNotFound from .instrument import beamline from .preprocessors import inject_haven_md_wrapper +from .xdi_export import XDIWriter log = logging.getLogger(__name__) diff --git a/src/haven/tests/test_asymmotron.py b/src/haven/tests/test_asymmotron.py new file mode 100644 index 00000000..4ee2f3ce --- /dev/null +++ b/src/haven/tests/test_asymmotron.py @@ -0,0 +1,229 @@ +import asyncio +import math +import time +from unittest.mock import AsyncMock + +import numpy as np +import pytest +from ophyd.sim import make_fake_device +from ophyd_async.core import get_mock_put, set_mock_value + +from haven.devices.asymmotron import ( + Analyzer, + Asymmotron, + bragg_to_energy, + bragg_to_wavelength, + energy_to_wavelength, + wavelength_to_bragg, + wavelength_to_energy, +) + +um_per_mm = 1000 + + +energy_to_wavelength_values = [ + # (eV, meters) + (61992.35, 0.2e-10), + (24796.94, 0.5e-10), + (12398.47, 1.0e-10), + (8041.555, 1.5418e-10), + (6199.235, 2.00e-10), + (2000.0, 6.19924e-10), +] + + +@pytest.mark.parametrize("energy, wavelength", energy_to_wavelength_values) +def test_energy_to_wavelength(energy, wavelength): + assert pytest.approx(energy_to_wavelength(energy)) == wavelength + + +@pytest.mark.parametrize("energy, wavelength", energy_to_wavelength_values) +def test_wavelength_to_energy(energy, wavelength): + assert pytest.approx(wavelength_to_energy(wavelength), rel=0.001) == energy + + +braggs_law_values = [ + # (θ°, d(Å), λ(Å)) + (35.424, 1.33, 1.5418), + (48.75, 1.33, 2.0), + (75, 1.33, 2.5694), + (50.43, 1.0, 1.5418), + (22.67, 2.0, 1.5418), +] + + +@pytest.mark.parametrize("theta, d_spacing, wavelength", braggs_law_values) +def test_bragg_to_wavelength(theta, d_spacing, wavelength): + theta = np.radians(theta) + d_spacing *= 1e-10 + wavelength *= 1e-10 + assert pytest.approx(bragg_to_wavelength(theta, d=d_spacing)) == wavelength + + +@pytest.mark.parametrize("theta, d_spacing, wavelength", braggs_law_values) +def test_wavelength_to_bragg(theta, d_spacing, wavelength): + theta = np.radians(theta) + d_spacing *= 1e-10 + wavelength *= 1e-10 + assert ( + pytest.approx(wavelength_to_bragg(wavelength, d=d_spacing), rel=0.001) == theta + ) + + +analyzer_values = [ + # (θB, α, β, y, x) + (70, 15, 25, 4.79, 47.60), + (80, 7, 10, 2.65, 49.40), + (60, 20, 30, 9.87, 43.56), + (65, 0, 0, 19.15, 41.07), + (80, 30, 10, -16.32, 46.98), +] + + +Si311_d_spacing = 1.637 * 1e-10 # converted to meters + + +@pytest.fixture() +async def xtal(sim_registry): + # Create the analyzer documents + xtal = Analyzer( + name="analyzer", + horizontal_motor_prefix="", + vertical_motor_prefix="", + yaw_motor_prefix="", + surface_plane=(0, 0, 1), + ) + await xtal.connect(mock=True) + # Set default values for xtal parameters + set_mock_value(xtal.d_spacing, Si311_d_spacing) + set_mock_value(xtal.rowland_diameter, 0.500) + return xtal + + +async def test_set_hkl(xtal): + await xtal.reflection.set("137") + hkl = await asyncio.gather( + xtal.reflection.h.get_value(), + xtal.reflection.k.get_value(), + xtal.reflection.l.get_value(), + ) + assert tuple(hkl) == (1, 3, 7) + + +@pytest.mark.parametrize("bragg,alpha,beta,y,x", analyzer_values) +async def test_rowland_circle_forward(xtal, bragg, alpha, beta, x, y): + # Set up sensible values for current positions + xtal.wedge_angle.get_value = AsyncMock(return_value=np.radians(beta)) + xtal.asymmetry_angle.get_value = AsyncMock(return_value=np.radians(alpha)) + xtal.d_spacing.get_value = AsyncMock(return_value=Si311_d_spacing) + bragg = np.radians(bragg) + energy = bragg_to_energy(bragg, d=Si311_d_spacing) + # Calculate the new x, z motor positions + calculated = await xtal.energy.forward( + energy, + D=xtal.rowland_diameter, + d=xtal.d_spacing, + beta=xtal.wedge_angle, + alpha=xtal.asymmetry_angle, + x=xtal.horizontal, + y=xtal.vertical, + ) + # Check the result is correct (convert cm -> m) + expected = {xtal.horizontal: x / 100, xtal.vertical: y / 100} + assert calculated == pytest.approx(expected, abs=0.001) + + +@pytest.mark.parametrize("bragg,alpha,beta,y,x", analyzer_values) +async def test_rowland_circle_inverse(xtal, bragg, alpha, beta, x, y): + # Calculate the expected answer + bragg = np.radians(bragg) + expected_energy = bragg_to_energy(bragg, d=Si311_d_spacing) + # Calculate the new energy + D = await xtal.rowland_diameter.get_value() + new_energy = xtal.energy.inverse( + { + xtal.horizontal: x, + xtal.vertical: y, + xtal.rowland_diameter: D, + xtal.d_spacing: Si311_d_spacing, + xtal.wedge_angle: np.radians(beta), + xtal.asymmetry_angle: np.radians(alpha), + }, + D=xtal.rowland_diameter, + d=xtal.d_spacing, + beta=xtal.wedge_angle, + alpha=xtal.asymmetry_angle, + x=xtal.horizontal, + y=xtal.vertical, + ) + # Compare to the calculated inverse + assert new_energy == pytest.approx(expected_energy, abs=0.2) + + +reflection_values = [ + # (cut, refl, α° ) + ("001", "101", 45.0), + ("001", "110", 90.0), + ("211", "444", 19.5), + ("211", "733", 4.0), + ("211", "880", 30.0), +] + + +@pytest.mark.parametrize("cut,refl,alpha", reflection_values) +async def test_asymmetry_angle(xtal, cut, refl, alpha): + await xtal.asymmetry_angle.connect(mock=False) + cut = tuple(int(i) for i in cut) + refl = tuple(int(i) for i in refl) + alpha = math.radians(alpha) + await xtal.reflection.set(refl) + await xtal.surface_plane.set(cut) + # Compare to the calculated inverse + new_alpha = await xtal.asymmetry_angle.get_value() + assert new_alpha == pytest.approx(alpha, abs=0.02) + + +d_spacing_values = [ + # Assuming a = 5.4311959Å + # https://www.globalsino.com/EM/page4489.html + # (hkl, d) + ("111", 3.135), + ("220", 1.920), + ("511", 1.045), + ("622", 0.819), +] + + +@pytest.mark.parametrize("hkl,d", d_spacing_values) +async def test_d_spacing(xtal, hkl, d): + await xtal.d_spacing.connect(mock=False) + await xtal.lattice_constant.set(5.4311959) + hkl = tuple(int(h) for h in hkl) # str to tuple + await xtal.reflection.set(hkl) + assert await xtal.d_spacing.get_value() == pytest.approx(d, abs=0.001) + + +# ----------------------------------------------------------------------------- +# :author: Mark Wolfman +# :email: wolfman@anl.gov +# :copyright: Copyright © 2023, UChicago Argonne, LLC +# +# Distributed under the terms of the 3-Clause BSD License +# +# The full license is in the file LICENSE, distributed with this software. +# +# DISCLAIMER +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# ----------------------------------------------------------------------------- diff --git a/src/haven/tests/test_lerix.py b/src/haven/tests/test_lerix.py deleted file mode 100644 index d7336c97..00000000 --- a/src/haven/tests/test_lerix.py +++ /dev/null @@ -1,167 +0,0 @@ -import time - -import pytest -from ophyd.sim import instantiate_fake_device - -from haven.devices import lerix - -um_per_mm = 1000 - - -def test_rowland_circle_forward(): - rowland = lerix.RowlandPositioner( - name="rowland", x_motor_pv="", y_motor_pv="", z_motor_pv="", z1_motor_pv="" - ) - # Check one set of values - um_per_mm - result = rowland.forward(500, 60.0, 30.0) - assert result == pytest.approx( - ( - 500.0 * um_per_mm, # x - 375.0 * um_per_mm, # y - 216.50635094610968 * um_per_mm, # z - 1.5308084989341912e-14 * um_per_mm, # z1 - ) - ) - # Check one set of values - result = rowland.forward(500, 80.0, 0.0) - assert result == pytest.approx( - ( - 484.92315519647707 * um_per_mm, # x - 0.0 * um_per_mm, # y - 171.0100716628344 * um_per_mm, # z - 85.5050358314172 * um_per_mm, # z1 - ) - ) - # Check one set of values - result = rowland.forward(500, 70.0, 10.0) - assert result == pytest.approx( - ( - 484.92315519647707 * um_per_mm, # x - 109.92315519647711 * um_per_mm, # y - 291.6982175363274 * um_per_mm, # z - 75.19186659021767 * um_per_mm, # z1 - ) - ) - # Check one set of values - result = rowland.forward(500, 75.0, 15.0) - assert result == pytest.approx( - ( - 500.0 * um_per_mm, # x - 124.99999999999994 * um_per_mm, # y - 216.50635094610965 * um_per_mm, # z - 2.6514380968122676e-14 * um_per_mm, # z1 - ) - ) - # Check one set of values - result = rowland.forward(500, 71.0, 10.0) - assert result == pytest.approx( - ( - 487.7641290737884 * um_per_mm, # x - 105.28431301548724 * um_per_mm, # y - 280.42235703910393 * um_per_mm, # z - 68.41033299999741 * um_per_mm, # z1 - ) - ) - - -@pytest.mark.xfail -def test_rowland_circle_inverse(): - rowland = instantiate_fake_device( - lerix.RowlandPositioner, - name="rowland", - x_motor_pv="", - y_motor_pv="", - z_motor_pv="", - z1_motor_pv="", - ) - # Check one set of values - result = rowland.inverse( - x=500.0, # x - y=375.0, # y - z=216.50635094610968, # z - z1=1.5308084989341912e-14, # z1 - ) - assert result == pytest.approx((500, 60, 30)) - # # Check one set of values - # result = rowland.forward(500, 80.0, 0.0) - # assert result == pytest.approx(( - # 484.92315519647707 * um_per_mm, # x - # 0.0 * um_per_mm, # y - # 171.0100716628344 * um_per_mm, # z - # 85.5050358314172 * um_per_mm, # z1 - # )) - # # Check one set of values - # result = rowland.forward(500, 70.0, 10.0) - # assert result == pytest.approx(( - # 484.92315519647707 * um_per_mm, # x - # 109.92315519647711 * um_per_mm, # y - # 291.6982175363274 * um_per_mm, # z - # 75.19186659021767 * um_per_mm, # z1 - # )) - # # Check one set of values - # result = rowland.forward(500, 75.0, 15.0) - # assert result == pytest.approx(( - # 500.0 * um_per_mm, # x - # 124.99999999999994 * um_per_mm, # y - # 216.50635094610965 * um_per_mm, # z - # 2.6514380968122676e-14 * um_per_mm, # z1 - # )) - # # Check one set of values - # result = rowland.forward(500, 71.0, 10.0) - # assert result == pytest.approx(( - # 487.7641290737884 * um_per_mm, # x - # 105.28431301548724 * um_per_mm, # y - # 280.42235703910393 * um_per_mm, # z - # 68.41033299999741 * um_per_mm, # z1 - # )) - - -def test_rowland_circle_component(): - device = instantiate_fake_device( - lerix.LERIXSpectrometer, prefix="255idVME", name="lerix" - ) - device.rowland.x.user_setpoint._use_limits = False - device.rowland.y.user_setpoint._use_limits = False - device.rowland.z.user_setpoint._use_limits = False - device.rowland.z1.user_setpoint._use_limits = False - # Set pseudo axes - statuses = [ - device.rowland.D.set(500.0), - device.rowland.theta.set(60.0), - device.rowland.alpha.set(30.0), - ] - # [s.wait() for s in statuses] # <- this should work, need to come back to it - time.sleep(0.1) - # Check that the virtual axes were set - result = device.rowland.get(use_monitor=False) - assert result.x.user_setpoint == pytest.approx(500.0 * um_per_mm) - assert result.y.user_setpoint == pytest.approx(375.0 * um_per_mm) - assert result.z.user_setpoint == pytest.approx(216.50635094610968 * um_per_mm) - assert result.z1.user_setpoint == pytest.approx(1.5308084989341912e-14 * um_per_mm) - - -# ----------------------------------------------------------------------------- -# :author: Mark Wolfman -# :email: wolfman@anl.gov -# :copyright: Copyright © 2023, UChicago Argonne, LLC -# -# Distributed under the terms of the 3-Clause BSD License -# -# The full license is in the file LICENSE, distributed with this software. -# -# DISCLAIMER -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# ----------------------------------------------------------------------------- diff --git a/src/haven/tests/test_xdi_writer.py b/src/haven/tests/test_xdi_writer.py new file mode 100644 index 00000000..d9695bc9 --- /dev/null +++ b/src/haven/tests/test_xdi_writer.py @@ -0,0 +1,394 @@ +import datetime as dt +import logging +import os +import time +from collections import ChainMap +from io import StringIO +from pathlib import Path + +import numpy as np +import pytest + +# from freezegun import freeze_time +import pytz +import time_machine +from bluesky import RunEngine +from numpy import asarray as array +from ophyd.sim import SynAxis, SynGauss, motor + +from haven import XDIWriter, energy_scan, exceptions + +fake_time = pytz.timezone("America/New_York").localize( + dt.datetime(2022, 8, 19, 19, 10, 51) +) + + +log = logging.getLogger(__name__) +# logging.basicConfig(level=logging.DEBUG) + + +# Stub the epics signals in Haven + +THIS_DIR = Path(__file__).parent + + +# Sample metadata dict +{ + "E0": 0, + "beamline": { + "is_connected": False, + "name": "SPC Beamline (sector unknown)", + "pv_prefix": "", + }, + "detectors": ["I0", "It"], + "edge": "Ni_K", + "facility": {"name": "Advanced Photon Source", "xray_source": "insertion device"}, + "hints": {"dimensions": [(["energy", "exposure"], "primary")]}, + "ion_chambers": {"scaler": {"pv_prefix": ""}}, + "motors": ["energy", "exposure"], + "num_intervals": 9, + "num_points": 10, + "plan_args": { + "args": [ + ( + "SynAxis(prefix='', name='energy', " + "read_attrs=['readback', 'setpoint'], " + "configuration_attrs=['velocity', 'acceleration'])" + ), + array([8300, 8310, 8320, 8330, 8340, 8350, 8360, 8370, 8380, 8390]), + ( + "SynAxis(prefix='', name='exposure', " + "read_attrs=['readback', 'setpoint'], " + "configuration_attrs=['velocity', 'acceleration'])" + ), + [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1], + ], + "detectors": [ + ( + "SynGauss(prefix='', name='I0', " + "read_attrs=['val'], configuration_attrs=['Imax', " + "'center', 'sigma', 'noise', 'noise_multiplier'])" + ), + ( + "SynGauss(prefix='', name='It', " + "read_attrs=['val'], configuration_attrs=['Imax', " + "'center', 'sigma', 'noise', " + "'noise_multiplier'])" + ), + ], + "per_step": "None", + }, + "plan_name": "list_scan", + "plan_pattern": "inner_list_product", + "plan_pattern_args": { + "args": [ + ( + "SynAxis(prefix='', name='energy', " + "read_attrs=['readback', 'setpoint'], " + "configuration_attrs=['velocity', " + "'acceleration'])" + ), + array([8300, 8310, 8320, 8330, 8340, 8350, 8360, 8370, 8380, 8390]), + ( + "SynAxis(prefix='', name='exposure', " + "read_attrs=['readback', 'setpoint'], " + "configuration_attrs=['velocity', " + "'acceleration'])" + ), + [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1], + ] + }, + "plan_pattern_module": "bluesky.plan_patterns", + "plan_type": "generator", + "sample_name": "NiO_rock_salt", + "scan_id": 1, + "time": 1661025010.409619, + "uid": "671c3c48-f014-421d-b3e0-57991b6745f6", + "versions": {"bluesky": "1.8.3", "ophyd": "1.6.4"}, +} + + +start_doc = { + "versions": {"bluesky": "1.8.3", "ophyd": "1.6.4"}, + "detectors": ["I0", "It"], + "d_spacing": 3.0, + "motors": ["energy", "exposure"], + "edge": "Ni_K", + "facility": { + "name": "Advanced Photon Source", + "xray_source": "insertion device", + }, + "beamline": {"name": "20-ID-C", "pv_prefix": "20id:"}, + "sample_name": "nickel oxide", + "uid": "671c3c48-f014-421d-b3e0-57991b6745f6", +} +event_doc = { + "data": { + "I0_net_counts": 2, + "It_net_counts": 1.5, + "IpreKB_net_counts": 2.5, + "Ipreslit_net_counts": 2.2, + "Iref_net_counts": 0.56, + "energy": 8330, + "exposure": 0.1, + "sim motor 1": 8330.0, + }, + "time": 1660186828.0055554, + "descriptor": "7ed5b8c5-045c-41fa-b79c-50fbcbe777e5", +} +descriptor_doc = { + "hints": { + "I0": {"fields": ["I0_net_counts"]}, + "IpreKB": {"fields": ["IpreKB_net_counts"]}, + "Ipreslit": {"fields": ["Ipreslit_net_counts"]}, + "Iref": {"fields": ["Iref_net_counts"]}, + "It": {"fields": ["It_net_counts"]}, + "sim motor 1": {"fields": ["sim motor 1"]}, + "energy": {"fields": ["energy"]}, + }, + "name": "primary", + "run_start": "6974290f-fe3f-4535-bb8c-c29c915f88aa", + "time": 1712865522.9266968, + "uid": "7ed5b8c5-045c-41fa-b79c-50fbcbe777e5", +} + + +@pytest.fixture() +def stringio(): + yield StringIO() + + +@pytest.fixture() +def file_path(tmp_path): + fp = tmp_path / "sample_file.txt" + try: + yield fp + finally: + fp.unlink() + + +@pytest.fixture() +def writer(stringio): + yield XDIWriter(stringio) + + +def test_opens_file(file_path): + # Pass in a string and it should hold the path until the start document is found + fp = file_path + writer = XDIWriter(fp) + assert writer.fp == fp + assert not fp.exists() + # Run the writer + writer("start", {"edge": "Ni_K"}) + # Check that a file was created + assert fp.exists() + + +def test_uses_open_file(stringio): + # Pass in a string and it should hold the path until the start document is found + writer = XDIWriter(stringio) + assert writer.fd is stringio + + +def test_read_only_file(file_path): + # Check that it raises an exception if an open file has no write intent + # Put some content in the temporary file + with open(file_path, mode="w") as fd: + fd.write("Hello, spam!") + # Check that the writer raises an exception when the file is open read-only + with open(file_path, mode="r") as fd: + with pytest.raises(exceptions.FileNotWritable): + XDIWriter(fd) + + +def test_required_headers(writer): + writer("start", start_doc) + writer("descriptor", descriptor_doc) + writer("event", event_doc) # Header gets written on first event + # Check that required headers were added to the XDI file + writer.fd.seek(0) + xdi_output = writer.fd.read() + assert "# XDI/1.0 bluesky/1.8.3 ophyd/1.6.4" in xdi_output + assert "# Column.1: energy" in xdi_output + assert "# Element.symbol: Ni" in xdi_output + assert "# Element.edge: K" in xdi_output + assert "# Mono.d_spacing: 3" in xdi_output # Not implemented yet + assert "# -------------" in xdi_output + + +@time_machine.travel(fake_time) +def test_optional_headers(writer): + os.environ["TZ"] = "America/New_York" + time.tzset() + writer("start", start_doc) + writer("descriptor", descriptor_doc) + writer("event", event_doc) + # Check that required headers were added to the XDI file + writer.fd.seek(0) + xdi_output = writer.fd.read() + expected_metadata = { + "Facility.name": "Advanced Photon Source", + "Facility.xray_source": "insertion device", + "Beamline.name": "20-ID-C", + "Beamline.pv_prefix": "20id:", + "Scan.start_time": "2022-08-19 19:10:51-0400", + "Column.8": "time", + "uid": "671c3c48-f014-421d-b3e0-57991b6745f6", + } + for key, val in expected_metadata.items(): + assert f"# {key.lower()}: {val.lower()}\n" in xdi_output.lower() + + +@time_machine.travel(fake_time) +def test_file_path_formatting(tmp_path): + """Check that "{date}_{user}.xdi" formatting works in the filename.""" + writer = XDIWriter(tmp_path / "{year}{month}{day}_{short_uid}_{sample_name}.xdi") + writer.start(start_doc) + assert str(writer.fp) == str(tmp_path / "20220819_671c3c48_nickel-oxide.xdi") + + +@time_machine.travel(fake_time) +def test_file_path_reentry(tmp_path): + """Check that "{date}_{user}.xdi" formatting can be used multiple times.""" + # Start the writer once with basic arguments + writer = XDIWriter(tmp_path / "{year}{month}{day}_{short_uid}_{sample_name}.xdi") + writer.start(start_doc) + target_path = str(tmp_path / "20220819_671c3c48_nickel-oxide.xdi") + assert str(writer.fp) == target_path + assert writer.fd.name == target_path + # Check that additional start docs don't create new files + writer.start(start_doc) + # Start the writer again with a second set of arguments + new_start_doc = ChainMap( + { + "sample_name": "manganese oxide", + "uid": "a6842eaa-6dd3-4666-83a0-1829cd687556", + }, + start_doc, + ) + writer.start(new_start_doc) + target_path = str(tmp_path / "20220819_a6842eaa_manganese-oxide.xdi") + assert str(writer.fp) == target_path + assert writer.fd.name == target_path + + +@time_machine.travel(fake_time) +def test_secondary_stream(tmp_path): + """Check that secondary data streams get ignored.""" + sec_event = ChainMap( + { + "descriptor": "b1006389-fd92-4037-9eb3-02332703552b", + "data": {"Iref": 2}, + }, + event_doc, + ) + sec_descriptor = ChainMap( + {"uid": sec_event["descriptor"], "name": "secondary"}, descriptor_doc + ) + # Set up the writer + writer = XDIWriter(tmp_path / "{year}{month}{day}_{short_uid}_{sample_name}.xdi") + writer.start(start_doc) + # Events before the descriptor should raise exceptions + assert writer._primary_uid == None + with pytest.raises(exceptions.DocumentNotFound): + writer.event(event_doc) + # Prime the writer with descriptor documents + writer.descriptor(descriptor_doc) + writer.descriptor(sec_descriptor) + # Send a correct primary data event + writer.event(event_doc) + # Send an event from secondary data stream + writer.event(sec_event) + + +@time_machine.travel(fake_time) +def test_manager_path(tmp_path, beamline_manager): + """Check that "{manager_path}.xdi" formatting works in the filename.""" + # Set up a full path on the beamline manager + beamline_manager.local_storage.full_path._readback = str(tmp_path) + "/" + # Create the XDI writer object + writer = XDIWriter( + "{manager_path}/{year}{month}{day}_{short_uid}_{sample_name}.xdi" + ) + writer.start(start_doc) + assert str(writer.fp) == f"{tmp_path}/20220819_671c3c48_nickel-oxide.xdi" + + +def test_file_path_formatting_bad_key(): + """Check that "{date}_{user}.xdi" raises exception if placeholder is invalid.""" + writer = XDIWriter("{year}{month}{day}_{spam}_{sample_name}.xdi") + with pytest.raises(exceptions.XDIFilenameKeyNotFound): + writer.start(start_doc) + + +def test_data(writer): + """Check that the TSV data section is present and correct.""" + writer.start(start_doc) + writer.descriptor(descriptor_doc) + writer("event", event_doc) + # Verify the data were written properly + writer.fd.seek(0) + xdi_output = writer.fd.read() + assert "8330" in xdi_output + assert "1660186828.0055554" in xdi_output + + +def test_with_plan(stringio, sim_registry, event_loop, beamline_manager, tmp_path): + beamline_manager.local_storage.full_path._readback = str(tmp_path) + "/" + I0 = SynGauss( + "I0", + motor, + "motor", + center=-0.5, + Imax=1, + sigma=1, + labels={"detectors"}, + ) + It = SynGauss( + "It", + motor, + "motor", + center=-0.5, + Imax=1, + sigma=1, + labels={"detectors"}, + ) + energy_motor = SynAxis(name="energy", labels={"motors", "energies"}) + exposure = SynAxis(name="exposure", labels={"motors", "exposures"}) + writer = XDIWriter(stringio) + RE = RunEngine() + energy_plan = energy_scan( + np.arange(8300.0, 8400.0, 10), + detectors=[I0, It], + E0="Ni_K", + energy_signals=[energy_motor], + time_signals=[exposure], + md=dict(sample_name="NiO_rock_salt"), + ) + RE(energy_plan, writer) + + +# ----------------------------------------------------------------------------- +# :author: Mark Wolfman +# :email: wolfman@anl.gov +# :copyright: Copyright © 2023, UChicago Argonne, LLC +# +# Distributed under the terms of the 3-Clause BSD License +# +# The full license is in the file LICENSE, distributed with this software. +# +# DISCLAIMER +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# ----------------------------------------------------------------------------- diff --git a/src/haven/xdi_export.py b/src/haven/xdi_export.py new file mode 100644 index 00000000..f8d53f51 --- /dev/null +++ b/src/haven/xdi_export.py @@ -0,0 +1,428 @@ +"""Implements a callback to automatically export scans to an XDI file. + +For now it's actually a csv file, but we'll get to the XDI part later. + +""" +import time +import numpy as np +import pandas as pd +import datetime as dt +import logging +import re +import unicodedata +import warnings +from pathlib import Path +from typing import Mapping, Optional, Sequence, Union +from httpx import HTTPStatusError + +from bluesky.callbacks import CallbackBase + +from . import exceptions +from .catalog import tiled_client + +log = logging.getLogger(__name__) + + +def slugify(value, allow_unicode=False): + """ + Taken from https://github.com/django/django/blob/master/django/utils/text.py + Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated + dashes to single dashes. Remove characters that aren't alphanumerics, + underscores, or hyphens. Also strip leading and + trailing whitespace, dashes, and underscores. + """ + value = str(value) + if allow_unicode: + value = unicodedata.normalize("NFKC", value) + else: + value = ( + unicodedata.normalize("NFKD", value) + .encode("ascii", "ignore") + .decode("ascii") + ) + return re.sub(r"[-\s]+", "-", value).strip("-_") + + +class XDIWriter(CallbackBase): + """A callback class for bluesky that will save data to an XDI file. + + File writing is done from tiled all at the end. + + The XAFS data interchange (XDI) format is a plain text, + tab-separated file format that standardizes XAFS data. Metadata is + also included in headers. + + The location of the file is determined by passing the *filename* + parameter in when creating the writer object: + + .. code-block:: python + + writer = XDIWriter(fd="./my_experiment.xdi") + + Placeholders can be included that will be filled-in with metadata + during the start phase of the plan, for example: + + .. code-block:: python + + plan = energy_scan(..., E0="Ni_K", md=dict(sample_name="nickel oxide")) + writer_callback = XDIWriter(fd="./{year}{month}{day}_{sample_name}_{edge}.xdi") + RE(plan, writer) + + Assuming the date is 2022-08-19, then the filename will become + "20220819_nickel-oxide_Ni_K.xdi". + + *{year}*, *{month}*, *{day}* describe the numerical, zero-padded + year, month and day when the callback handles the start document + for the plan. *{short_uid}* is the portion of the scan UID up to + the first '-' character. *{manager_path}* will be the full path + specified by the device labeled "beamline_manager" that has a + ``local_storage.full_path`` signal. The remaining fields in the + start document are also available, and will likely vary from plan + to plan. Either consult the documentation for the plan being + executed, or inspect the logs for the dictionary metadata at level + ``logging.INFO`` when using an invalid placeholder. + + Parameters + ========== + fd + Either an open file with write intent, or a Path or string with + the file location. If *fd* is not already an open file, it will + be created when the ``start()`` method is called. + client + A tiled client used to retrieve data. + sep + Separator to use for saving data + metadata_keys + Additional metadata to pull from the start document into XDI + header. + + """ + + fp: Optional[Union[str, Path]] = None + column_names: Optional[Sequence[str]] = None + start_time: dt.datetime = None + stream_name: str = "primary" + + start_doc: Mapping = None + descriptor_doc: Mapping = None + + rois = [] # <- just for Jerry's beamtime + + _fd = None + _fp_template: Optional[Union[str, Path]] = None + _last_uid: str = "" + _primary_uid: str + _primary_descriptor: Mapping + _secondary_uids: Sequence[str] + + def __init__(self, fd: Union[Path, str], client=None, sep="\t", metadata_keys=[], *args, **kwargs): + self.sep = sep + self.metadata_keys = metadata_keys + is_file_obj = hasattr(fd, "writable") + if is_file_obj: + # *fd* is an open file object + # Make sure it is writable + if fd.writable(): + log.debug(f"Found open, writable file: {fd}") + self._fd = fd + else: + msg = f"No write intent on file: {fd}" + log.error(msg) + raise exceptions.FileNotWritable(msg) + else: + # Assume *fd* is a path to a file + self._fp_template = Path(fd).expanduser() + self.fp = self._fp_template + # Load the tiled client to retrieve data later + self.client = client + if self.client is None: + self.client = tiled_client() + return super().__init__(*args, **kwargs) + + @property + def fd(self): + """Retrieve the open writable file object for this writer.""" + if self._fd is None: + log.debug(f"Opening file: {self.fp.resolve()}") + self.fp.parent.mkdir(exist_ok=True, parents=True) + self._fd = open(self.fp, mode="x") + return self._fd + + def load_dataframe(self, uid: str): + rois = self.rois + ion_chambers = ['IpreKB', 'I0'] + run = self.client[uid] + data = run['primary/data'].read() + cfg = run['primary/config'] + imgs = np.sum(data['eiger_image'].compute(), axis=1) + roi_data = {f"roi{idx}": imgs[:, slice(*roi[0]), slice(*roi[1])] for idx, roi in enumerate(rois)} + roi_data['total'] = imgs + roi_data = {key: np.sum(arr, axis=(1, 2)) for key, arr in roi_data.items()} + try: + energy = data['energy'] + except KeyError: + pass + else: + roi_data['energy /eV'] = energy + for analyzer_idx in range(4): + key = f"analyzer{analyzer_idx}-energy" + try: + roi_data[key] = data[key] + except KeyError: + pass + I0 = data["I0-net_current"] + time = cfg['eiger/eiger_cam_acquire_time'][0].compute() + corr = {f"{key}_corr": vals / I0 / time for key, vals in roi_data.items()} + roi_data.update(corr) + # Add extra signals + extra_signals = [ + "I0-net_current", + "I0-mcs-scaler-channels-3-net_count", + "I0-mcs-scaler-channels-0-raw_count", + "IpreKB-net_current", + "IpreKB-mcs-scaler-channels-2-net_count", + "IpreKB-mcs-scaler-channels-0-raw_count", + ] + for sig in extra_signals: + roi_data[sig] = data[sig] + df = pd.DataFrame(roi_data) + return df + + def file_path(self, start_doc: Mapping): + """Prepare the metadata for string formatting the output file path.""" + start_time = dt.datetime.fromtimestamp(start_doc['time']) + md = { + "year": start_time.strftime("%Y"), + "month": start_time.strftime("%m"), + "day": start_time.strftime("%d"), + "hour": start_time.strftime("%H"), + "minute": start_time.strftime("%M"), + "second": start_time.strftime("%S"), + } + # Add a shortened version of the UID + try: + md["short_uid"] = start_doc["uid"].split("-")[0] + except KeyError: + pass + # Add local storage directory + md.update(start_doc) + # Make the file name + fp = str(self._fp_template) + try: + fp = fp.format(**md) + except KeyError as e: + msg = f"Could not find match key {e} in {fp}." + log.exception(msg) + log.info(f"Metadata is: {md}") + raise exceptions.XDIFilenameKeyNotFound(msg) from None + fp = slugify(fp) + fp = Path(fp) + return fp + + def write_xdi_file(self, uid: str, overwrite=False): + t0 = time.monotonic() + timeout = 20 + scan = None + # Retrieve scan info from the database + scan = self.client[uid] + start_doc = scan.metadata['start'] + df = None + while df is None: + try: + df = self.load_dataframe(uid) + except HTTPStatusError: + print(time.monotonic() - t0) + if (time.monotonic() - t0) < timeout: + time.sleep(0.1) + continue + else: + raise + # Create the file + fp = self.file_path(scan.metadata['start']) + if overwrite: + mode = "w" + else: + mode = "x" + with open(fp, mode=mode) as fd: + # Write header + self.write_header(doc=start_doc, fd=fd, column_names=df.columns) + # Write data + df.to_csv(fd, sep=self.sep, index_label="index") + print(f"Wrote scan to file: {str(fp)}.") + + def stop(self, doc): + self.write_xdi_file(doc["run_start"]) + + # def start(self, doc): + # self.start_time = dt.datetime.now().astimezone() + # self.column_names = None + # self._primary_uid = None + # self._secondary_uids = [] + # # Format the file name based on metadata + # is_new_uid = doc.get("uid", "") != self._last_uid + # if self._fp_template is not None: + # # Make sure any previous runs are closed + # if is_new_uid: + # self.close() + # fp = str(self._fp_template) + # md = self._path_metadata(doc=doc) + # try: + # fp = fp.format(**md) + # except KeyError as e: + # msg = f"Could not find match key {e} in {fp}." + # log.error(msg) + # log.info(f"Metadata is: {md}") + # raise exceptions.XDIFilenameKeyNotFound(msg) from None + # fp = slugify(fp) + # self.fp = Path(fp) + # # Save the rest of the start doc so we can write the headers + # # when we get our first datum + # self.start_doc = doc + # self._last_uid = doc.get("uid", "") + # # Open the file, just to be sure we can + # self.fd + + # def descriptor(self, doc): + # if doc["name"] == self.stream_name: + # self._primary_uid = doc["uid"] + # self._primary_descriptor = doc + # else: + # self._secondary_uids.append(doc["uid"]) + # return + # # Determine columns to use for storing events later + # if self.column_names is None: + # # Get column names from the scan hinted signals + # names = [val["fields"] for val in doc["hints"].values()] + # names = [n for sublist in names for n in sublist] + # # Sort column names so that energy-related fields are first + # names = sorted(names, key=lambda x: not x.startswith("energy")) + # self.column_names = names + ["time"] + # self.write_header(self.start_doc) + # # Save the descriptor doc for later + # self.descriptor_doc = doc + + # def close(self): + # """Ensure any open files are closed.""" + # try: + # self._fd.close() + # except ValueError: + # log.debug(f"Could not close file: {self.fd}") + # except AttributeError: + # log.debug(f"No file to close, skipping.") + # else: + # self._fd = None + + def write_header(self, doc, fd, column_names): + # Write package version information + versions = ["XDI/1.0"] + versions += [f"{name}/{ver}" for name, ver in doc.get("versions", {}).items()] + fd.write(f"# {' '.join(versions)}\n") + # Column Names + columns = [ + f"# Column.{num+1}: {name}\n" for num, name in enumerate(column_names) + ] + fd.write("".join(columns)) + # X-ray edge information + edge_str = doc.get("edge", None) + try: + elem, edge = edge_str.split("_") + except (AttributeError, ValueError): + msg = f"Could not parse X-ray edge metadata: {edge_str}" + warnings.warn(msg) + log.warning(msg) + else: + fd.write(f"# Element.symbol: {elem}\n") + fd.write(f"# Element.edge: {edge}\n") + # Monochromator information + try: + d_spacing = doc["d_spacing"] + except KeyError: + pass + else: + fd.write(f"# Mono.d_spacing: {d_spacing}\n") + # User requested metadata + if "sample_name" in doc: + fd.write(f"# Sample.name: {doc['sample_name']}\n") + for key in self.metadata_keys: + fd.write(f"# User.{key}: {doc[key]}\n") + # Facility information + now = dt.datetime.fromtimestamp(doc['time']) + fd.write(f"# Scan.start_time: {now.strftime('%Y-%m-%d %H:%M:%S%z')}\n") + md_paths = [ + "facility.name", + "facility.xray_source", + "beamline.name", + "beamline.pv_prefix", + ] + for path in md_paths: + val = doc + try: + for piece in path.split("."): + val = val[piece] + fd.write(f"# {path}: {val}\n") + except KeyError: + continue + # Scan ID + fd.write(f"# uid: {doc.get('uid', '')}\n") + # Header end token + fd.write("# -------------\n") + + # def event(self, doc): + # """Save the data in tab-separated value, in order of + # ``self.column_names``. + + # """ + # # Ignore non-primary data streams + # if doc["descriptor"] in self._secondary_uids: + # return + # elif doc["descriptor"] != self._primary_uid: + # # We're getting data out of order, so we can't save to the file + # msg = ( + # "No descriptor document available. " + # "The descriptor document should be passed to " + # f"{self}.descriptor() before providing event documents." + # ) + # raise exceptions.DocumentNotFound(msg) + # # Read in and store the actual data + # data = doc["data"] + # fd = self.fd + # values = [] + # for col in self.column_names: + # if col == "time": + # values.append(str(doc["time"])) + # else: + # try: + # values.append(str(data[col])) + # except KeyError: + # msg = f"Could not find signal {col} in datum." + # log.warning(msg) + # warnings.warn(msg) + # line = "\t".join(values) + # fd.write(line + "\n") + + +# ----------------------------------------------------------------------------- +# :author: Mark Wolfman +# :email: wolfman@anl.gov +# :copyright: Copyright © 2023, UChicago Argonne, LLC +# +# Distributed under the terms of the 3-Clause BSD License +# +# The full license is in the file LICENSE, distributed with this software. +# +# DISCLAIMER +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# ----------------------------------------------------------------------------- +