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
+
+
+
+ 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.
+#
+# -----------------------------------------------------------------------------
+