hkl_soleil E6C \(\psi\) (psi) axis#

Show how to set, compute, and scan \(\psi\) with the E6C diffractometer geometry. Use the hkl_soleil solver. Scan \(\psi\) at fixed \(Q\) and \(hkl_2\).

Virtual axes, such as \(\psi\), are features provided by the solver as extras. Extras are not necessarily available in every solver. Consult the solver documentation for details.

Concise Summary#

Define an E6C diffractometer object. Add a sample. Add two known reflections, and compute its \(UB\) matrix

e6c_hkl = diffractometer_factory(name="e6c_hkl", geometry="E6C")
e6c_hkl.add_sample("vibranium", 2 * math.pi, digits=5)
e6c_hkl.add_reflection((4, 0, 0), (0, 29.354, 0, 2, 0, 58.71), name="r400")
e6c_hkl.add_reflection((0, 4, 0), (0, 29.354, 0, 92, 0, 58.71), name="r040")
for r in e6c_hkl.sample.reflections.order:
    print(f"{e6c_hkl.sample.reflections[r]}")
e6c_hkl.operator.calc_UB(*e6c_hkl.sample.reflections.order)

Set \(\psi\)#

Use the "psi_constant_vertical" mode. Make a dictionary with \(hkl_2\) and \(\psi\). Finally, compute the real-space position at \(hkl\).

e6c_hkl.operator.solver.mode = "psi_constant_vertical"
e6c_hkl.operator.solver.extras = dict(h2=1, k2=1, l2=0, psi=12)
p_111 = e6c_hkl.forward(1, 1, 1)

Compute \(\psi\)#

Create a second E6C diffractometer object using the "psi" computation engine. Copy the \(UB\) matrix from the e6c_hkl diffractometer. Set \(hkl_2\). (Since these are simulators, copy the real-space motor positions.) Show the position of \(\psi\).

e6c_psi = diffractometer_factory(
    name="e6c_psi", geometry="E6C", solver_kwargs={"engine": "psi"})
e6c_psi.operator.solver.UB = e6c_hkl.operator.solver.UB
e6c_psi.operator.solver.extras = dict(h2=1, k2=1, l2=0)
e6c_psi.move_reals(p_111)
print(f"{e6c_psi.psi.position})

Scan \(\psi\)#

Run the diffractometer’s custom scan_extra() plan, specifying both \(hkl\) (as pseudos) and \(hkl_2\) (as extras).

(uid,) = RE(
    e6c_hkl.scan_extra(
        [noisy_det, e6c_hkl],
        axis="psi",
        start=0.1,
        finish=150,
        num=15,
        pseudos=dict(h=0, k=0, l=2),
        extras=dict(h2=1, k2=2, l2=0),
    ),
)

NOTE

The demonstrations below rely on features provided by the hkl_soleil solver.


Overview#

To work with \(\psi\) we’ll use the "hkl" engine of the E6C geometry. To compute \(\psi\) we’ll use the "psi" engine. This table summarizes our use:

engine

how it is used

"hkl"

work in reciprocal-space coordinates \(h, k, l\)

"psi"

compute the \(\psi\) rotation angle (not for operations)

\(\psi\) is the rotation of reference vector \(hkl_2\) perpendicular to scattering vector \(Q\):

2c1b564dd51f43f6b0cd498a33ee60b6

color

description

blue

incident and exit X-ray beams

green

scattering vector (\(Q\))

red

reference vector (\(hkl_2\))

yellow

rotation (\(\psi\)) from \(hkl_2\) around \(Q\)

black

principle cartesian axes

gray

sample

Steps#

  1. With the "hkl" engine:

    1. Orient a crystalline sample with the "hkl" engine.

    2. Define the azimuthal reflection \(h_2, k_2, l_2\) and a \(\psi\) rotation.

    3. Position the diffractometer for the \(h, k, l\) reflection.

  2. With the "psi" engine:

    1. Copy sample and orientation information from the "hkl" instance.

    2. Copy position information:

      • This step is necessary since this notebook uses simulated motors.

      • Diffractometers using EPICS motors will do this automatically.

    3. Compute psi.

    4. Compare the computed psi value with the value set with the "hkl" instance.

  3. Scan \(\psi\) at fixed \(Q\) and \(hkl_2\).

Setup E6C Simulators#

Create instances of (simulated) E6C for the "hkl" and "psi" solver engines. The hklpy2.creator() function creates both.

[1]:
import hklpy2

e6c_hkl = hklpy2.creator(
    name="e6c_hkl",
    geometry="E6C",
    solver="hkl_soleil",
    solver_kwargs={"engine": "hkl"},
)
e6c_psi = hklpy2.creator(
    name="e6c_psi",
    geometry="E6C",
    solver="hkl_soleil",
    solver_kwargs={"engine": "psi"},
)

Show the different calculation engines available for the E6C geometry.

[2]:
print(f"{e6c_hkl.operator.solver.engines=}")
e6c_hkl.operator.solver.engines=['hkl', 'psi', 'q2', 'qper_qpar', 'tth2', 'incidence', 'emergence']

NOTE

The solver works at a lower level than ophyd. All the code and structures used by a solver are pure Python code (or calls from Python to lower level libraries.)


Show the different operation modes available with each engine for the E6C geometry.

The hkl engine has a "psi_constant_vertical" mode that can be used to calculate reals given some fixed parameters (UB, wavelength, \((hkl)\), \((hkl)_2\), \(\psi\)). The psi engine has only one mode.

[3]:
print(f"{e6c_hkl.operator.solver.modes=}")
print(f"{e6c_psi.operator.solver.modes=}")
e6c_hkl.operator.solver.modes=['bissector_vertical', 'constant_omega_vertical', 'constant_chi_vertical', 'constant_phi_vertical', 'lifting_detector_phi', 'lifting_detector_omega', 'lifting_detector_mu', 'double_diffraction_vertical', 'bissector_horizontal', 'double_diffraction_horizontal', 'psi_constant_vertical', 'psi_constant_horizontal', 'constant_mu_horizontal']
e6c_psi.operator.solver.modes=['psi_vertical']

Show the extra axes available with each mode used by this notebook. (The extras have default values at this time.)

The psi engine has a pseudo axis "psi" that can be used to calculate \(\psi\) given some fixed parameters (reals, UB, wavelength, \((hkl)\), \((hkl)_2\))

[4]:
e6c_hkl.operator.solver.mode = "bissector_vertical"
print(f"{e6c_hkl.operator.solver.mode=}")
print(f"{e6c_hkl.operator.solver.extras=}")

e6c_hkl.operator.solver.mode = "psi_constant_vertical"
print(f"{e6c_hkl.operator.solver.mode=}")
print(f"{e6c_hkl.operator.solver.extras=}")

# "psi" engine has only one mode, do not need to set it
print(f"{e6c_psi.operator.solver.mode=}")
print(f"{e6c_psi.operator.solver.extras=}")
e6c_hkl.operator.solver.mode='bissector_vertical'
e6c_hkl.operator.solver.extras={}
e6c_hkl.operator.solver.mode='psi_constant_vertical'
e6c_hkl.operator.solver.extras={'h2': 1.0, 'k2': 0.0, 'l2': 0.0, 'psi': 0.0}
e6c_psi.operator.solver.mode='psi_vertical'
e6c_psi.operator.solver.extras={'h2': 1.0, 'k2': 1.0, 'l2': 1.0}

Define and orient a sample#

The sample for this notebook is crystalline vibranium, with a cubic lattice of exactly \(2\pi\). With it mounted on oru diffractometer, we have identified two reflections which define its orientation.

[5]:
import math

e6c_hkl.wavelength.put(1.54)  # angstrom (8.0509 keV)

e6c_hkl.add_sample("vibranium", 2 * math.pi, digits=5)

e6c_hkl.add_reflection((4, 0, 0), (0, 29.354, 0, 2, 0, 58.71), name="r400")
e6c_hkl.add_reflection((0, 4, 0), (0, 29.354, 0, 92, 0, 58.71), name="r040")
for r in e6c_hkl.sample.reflections.order:
    print(f"{e6c_hkl.sample.reflections[r]}")
e6c_hkl.operator.calc_UB(*e6c_hkl.sample.reflections.order)

print(f"{e6c_hkl.operator.solver.UB=!r}")
print(f"{e6c_hkl.operator.solver.U=!r}")
Reflection(name='r400', geometry='E6C', pseudos={'h': 4, 'k': 0, 'l': 0}, reals={'mu': 0, 'omega': 29.354, 'chi': 0, 'phi': 2, 'gamma': 0, 'delta': 58.71}, wavelength=1.54, digits=4)
Reflection(name='r040', geometry='E6C', pseudos={'h': 0, 'k': 4, 'l': 0}, reals={'mu': 0, 'omega': 29.354, 'chi': 0, 'phi': 92, 'gamma': 0, 'delta': 58.71}, wavelength=1.54, digits=4)
e6c_hkl.operator.solver.UB=[[0.034882054037, 0.999391435978, -0.0], [0.0, 0.0, 1.0], [0.999391435978, -0.034882054037, -0.0]]
e6c_hkl.operator.solver.U=[[0.034882054037, 0.999391435978, 0.0], [0.0, 0.0, 1.0], [0.999391435978, -0.034882054037, 0.0]]

Move to the \((111)\) orientation#

Before moving the diffractometer, ensure you have selected the desired operating mode.

[6]:
e6c_hkl.operator.solver.mode = "bissector_vertical"
e6c_hkl.move(1, 0, 0)
e6c_hkl.position, e6c_hkl.real_position
[6]:
(Hklpy2DiffractometerPseudoPos(h=1.00000000737, k=-8.2488e-08, l=0),
 Hklpy2DiffractometerRealPos(mu=0, omega=7.039253278732, chi=0, phi=1.998995273774, gamma=0, delta=14.078506557465))

Set \({hkl}_2\) and \(\psi\)#

Show the extra axes available with psi_constant_vertical mode.

[7]:
e6c_hkl.operator.solver.mode = "psi_constant_vertical"
print(f"{e6c_hkl.operator.solver.extra_axis_names=}")
e6c_hkl.operator.solver.extra_axis_names=['h2', 'k2', 'l2', 'psi']

Set azimuthal reflection \({hkl}_2 = (110)\) and \(\psi=12\).

The extras are described as a Python dictionary with values for each of the parameters.

[8]:
e6c_hkl.operator.solver.extras = dict(h2=1, k2=1, l2=0, psi=12)
print(f"{e6c_hkl.operator.solver.extras=}")
e6c_hkl.operator.solver.extras={'h2': 1.0, 'k2': 1.0, 'l2': 0.0, 'psi': 12.0}

Compute the real-axis motor values with the \(Q=(111)\) reflection oriented and \(\psi\) rotation.

[9]:
p_111 = e6c_hkl.forward(1, 1, 1)
print(f"{p_111=}")
p_111=Hklpy2DiffractometerRealPos(mu=0, omega=66.391607045543, chi=99.77381778179, phi=-49.997332854697, gamma=0, delta=24.509844391025)

Move each real (real-space positioner) to the computed \((111)\) reflection position p_111.

[10]:
e6c_hkl.move_reals(p_111)
print(f"{e6c_hkl.position=}")
print(f"{e6c_hkl.real_position=}")
print(f"{e6c_hkl.operator.solver.extras=}")
e6c_hkl.position=Hklpy2DiffractometerPseudoPos(h=1.000000009255, k=0.999999994159, l=0.999999984039)
e6c_hkl.real_position=Hklpy2DiffractometerRealPos(mu=0, omega=66.391607045543, chi=99.77381778179, phi=-49.997332854697, gamma=0, delta=24.509844391025)
e6c_hkl.operator.solver.extras={'h2': 1.0, 'k2': 1.0, 'l2': 0.0, 'psi': 12.0}

Compute \(\psi\) at fixed \(Q\) and \(hkl_2\)#

We’ll use the "psi" engine to compute \(\psi\), given a sample & orientation, \({hkl}_2\), and the real-space motor positions.

[11]:
print(f"{e6c_psi.operator.solver.mode=}")
print(f"{e6c_psi.operator.solver.extras=}")
e6c_psi.operator.solver.mode='psi_vertical'
e6c_psi.operator.solver.extras={'h2': 1.0, 'k2': 1.0, 'l2': 1.0}

Same sample and lattice

[12]:
e6c_psi.add_sample("vibranium", 2 * math.pi, digits=5)
[12]:
Sample(name='vibranium', lattice=Lattice(a=6.28319, system='cubic'))

Copy orientation from hkl instance. Note the psi and hkl UB matrices are not exactly equal. Equal to about 5 decimal places.)

[13]:
e6c_psi.operator.solver.UB = e6c_hkl.operator.solver.UB

print(f"{e6c_psi.operator.solver.UB=!r}")
print(f"{e6c_psi.operator.solver.U=!r}")

print(f"{e6c_hkl.operator.solver.UB=!r}")
print(f"{e6c_hkl.operator.solver.U=!r}")
e6c_psi.operator.solver.UB=[[0.034882112737, 0.999391462637, -7.7669e-08], [-1.1035e-07, 3.7043e-08, 0.999999954315], [0.999391567978, -0.034881973051, -8.4609e-08]]
e6c_psi.operator.solver.U=[[0.034882108064, 0.999391434092, -3.3171e-08], [-1.1035e-07, 3.7043e-08, 1.0], [0.999391434092, -0.034882108064, 1.11575e-07]]
e6c_hkl.operator.solver.UB=[[0.034882054037, 0.999391435978, -0.0], [0.0, 0.0, 1.0], [0.999391435978, -0.034882054037, -0.0]]
e6c_hkl.operator.solver.U=[[0.034882054037, 0.999391435978, 0.0], [0.0, 0.0, 1.0], [0.999391435978, -0.034882054037, 0.0]]

Set \({hkl}_2=(1, 1, 0)\). As above, describe these parameters in a Python dictionary.

[14]:
e6c_psi.operator.solver.extras = dict(h2=1, k2=1, l2=0)
print(f"{e6c_psi.operator.solver.extras=}")
e6c_psi.operator.solver.extras={'h2': 1.0, 'k2': 1.0, 'l2': 0.0}

Set real-space axis positions from p_111 (above).

[15]:
e6c_psi.move_reals(p_111)
print(f"{e6c_psi.pseudo_axis_names=}")
print(f"{e6c_psi.operator.solver.pseudo_axis_names=}")
print(f"{e6c_psi.position=}")
print(f"{e6c_psi.real_position=}")
e6c_psi.pseudo_axis_names=['psi']
e6c_psi.operator.solver.pseudo_axis_names=['psi']
e6c_psi.position=Hklpy2DiffractometerPseudoPos(psi=11.999993753339)
e6c_psi.real_position=Hklpy2DiffractometerRealPos(mu=0, omega=66.391607045543, chi=99.77381778179, phi=-49.997332854697, gamma=0, delta=24.509844391025)

Compare hkl and psi reports.

[16]:
print(e6c_hkl)
e6c_hkl.wh()
print(e6c_psi)
e6c_psi.wh()
Hklpy2Diffractometer(prefix='', name='e6c_hkl', settle_time=0.0, timeout=None, egu='', limits=(0, 0), source='computed', read_attrs=['h', 'h.readback', 'h.setpoint', 'k', 'k.readback', 'k.setpoint', 'l', 'l.readback', 'l.setpoint', 'mu', 'omega', 'chi', 'phi', 'gamma', 'delta'], configuration_attrs=['geometry', 'solver', 'wavelength', 'h', 'k', 'l'], concurrent=True)
h=1.0 k=1.0 l=1.0
wavelength=1.54
mu=0 omega=66.3916 chi=99.7738 phi=-49.9973 gamma=0 delta=24.5098
h2=1.0 k2=1.0 l2=0 psi=12.0
Hklpy2Diffractometer(prefix='', name='e6c_psi', settle_time=0.0, timeout=None, egu='', limits=(0, 0), source='computed', read_attrs=['psi', 'psi.readback', 'psi.setpoint', 'mu', 'omega', 'chi', 'phi', 'gamma', 'delta'], configuration_attrs=['geometry', 'solver', 'wavelength', 'psi'], concurrent=True)
psi=12.0
wavelength=1.0
mu=0 omega=66.3916 chi=99.7738 phi=-49.9973 gamma=0 delta=24.5098
h2=1.0 k2=1.0 l2=0

Scan \(\psi\) at fixed \(Q\) and \(hkl_2\)#

Setup the bluesky tools needed to run scans and review data.

[17]:
import databroker

from bluesky import RunEngine
from bluesky.callbacks.best_effort import BestEffortCallback
from ophyd.sim import noisy_det

from hklpy2 import ConfigurationRunWrapper

# Save orientation of the diffractometer.
crw = ConfigurationRunWrapper(e6c_hkl)

bec = BestEffortCallback()
bec.disable_plots()
cat = databroker.temp().v2
RE = RunEngine()
RE.subscribe(cat.v1.insert)
RE.subscribe(bec)
RE.preprocessors.append(crw.wrapper)

Scan \(\psi\) over a wide range in coarse steps.


NOTE

Since \(\psi\) is an extra axis, it is only available with certain operation modes, such as "psi_constant_vertical". Be sure to set that before scanning. The plan will raise a KeyError if the axis name is not recognized. Any extra axes are not ophyd objects since they are defined only when certain modes are selected. A custom plan is provided which scans an extra axis, while holding any pseudos or reals, and other extras at constant values.


This example chooses \(Q=(002)\) and \(hkl_2=(120)\). (The reference \(hkl_2\) was chosen to be perpendicular to \(Q\).) Save the uid from the scan for later reference.

To control the solution space, we adjust the low limit of both \(\phi\) and \(\omega\) so their ranges are limited to \(0..180^o\).

The e6c_hkl diffractometer is added as a detector here so that all the positioner values will be available for plotting later.

[18]:
e6c_hkl.operator.solver.mode = "psi_constant_vertical"
e6c_hkl.operator.constraints["phi"].low_limit = 0
e6c_hkl.operator.constraints["omega"].low_limit = 0
(uid,) = RE(
    e6c_hkl.scan_extra(
        [noisy_det, e6c_hkl],
        axis="psi",
        start=0.1,
        finish=150,
        num=15,
        pseudos=dict(h=0, k=0, l=2),
        extras=dict(h2=1, k2=2, l2=0),
    ),
)


Transient Scan ID: 1     Time: 2025-02-18 17:34:20
Persistent Unique Scan ID: '20fd8d60-d571-45ef-ab12-8c2d424cb81d'
New stream: 'primary'
+-----------+------------+------------+--------------------+------------+------------+------------+------------+---------------+-------------+-------------+---------------+---------------+
|   seq_num |       time |  noisy_det | e6c_hkl_extras_psi |  e6c_hkl_h |  e6c_hkl_k |  e6c_hkl_l | e6c_hkl_mu | e6c_hkl_omega | e6c_hkl_chi | e6c_hkl_phi | e6c_hkl_gamma | e6c_hkl_delta |
+-----------+------------+------------+--------------------+------------+------------+------------+------------+---------------+-------------+-------------+---------------+---------------+
|         1 | 17:34:20.0 |      0.900 |              0.100 |     -0.000 |     -0.000 |      2.000 |          0 |       165.812 |      90.000 |     155.334 |             0 |       -28.375 |
|         2 | 17:34:20.0 |      1.087 |             10.807 |      0.000 |      0.000 |      2.000 |          0 |       165.812 |      90.000 |     144.627 |             0 |       -28.375 |
|         3 | 17:34:20.0 |      1.097 |             21.514 |     -0.000 |      0.000 |      2.000 |          0 |       165.812 |      90.000 |     133.920 |             0 |       -28.375 |
|         4 | 17:34:20.0 |      0.913 |             32.221 |     -0.000 |      0.000 |      2.000 |          0 |       165.812 |      90.000 |     123.213 |             0 |       -28.375 |
|         5 | 17:34:20.1 |      1.078 |             42.929 |      0.000 |      0.000 |      2.000 |          0 |       165.812 |      90.000 |     112.505 |             0 |       -28.375 |
|         6 | 17:34:20.1 |      1.031 |             53.636 |     -0.000 |      0.000 |      2.000 |          0 |       165.812 |      90.000 |     101.798 |             0 |       -28.375 |
|         7 | 17:34:20.1 |      1.080 |             64.343 |     -0.000 |     -0.000 |      2.000 |          0 |       165.812 |      90.000 |      91.091 |             0 |       -28.375 |
|         8 | 17:34:20.1 |      1.016 |             75.050 |     -0.000 |      0.000 |      2.000 |          0 |       165.812 |      90.000 |      80.384 |             0 |       -28.375 |
|         9 | 17:34:20.1 |      1.078 |             85.757 |     -0.000 |      0.000 |      2.000 |          0 |       165.812 |      90.000 |      69.677 |             0 |       -28.375 |
|        10 | 17:34:20.1 |      1.014 |             96.464 |      0.000 |      0.000 |      2.000 |          0 |       165.812 |      90.000 |      58.970 |             0 |       -28.375 |
|        11 | 17:34:20.1 |      1.086 |            107.171 |     -0.000 |     -0.000 |      2.000 |          0 |       165.812 |      90.000 |      48.263 |             0 |       -28.375 |
|        12 | 17:34:20.1 |      1.042 |            117.879 |     -0.000 |     -0.000 |      2.000 |          0 |       165.812 |      90.000 |      37.555 |             0 |       -28.375 |
|        13 | 17:34:20.1 |      1.022 |            128.586 |     -0.000 |      0.000 |      2.000 |          0 |       165.812 |      90.000 |      26.848 |             0 |       -28.375 |
|        14 | 17:34:20.1 |      1.035 |            139.293 |      0.000 |     -0.000 |      2.000 |          0 |       165.812 |      90.000 |      16.141 |             0 |       -28.375 |
|        15 | 17:34:20.1 |      1.034 |            150.000 |      0.000 |     -0.000 |      2.000 |          0 |       165.812 |      90.000 |       5.434 |             0 |       -28.375 |
+-----------+------------+------------+--------------------+------------+------------+------------+------------+---------------+-------------+-------------+---------------+---------------+
generator scan_extra ['20fd8d60'] (scan num: 1)



Plot any motions#

The only real-space axis to be moved by this scan is \(\phi\). Plot \(\phi\) vs. \(\psi\).

axis

data name

\(\phi\)

e6c_hkl_phi

\(\psi\)

e6c_hkl_extras_psi


NOTE

Extra axes are named with the _extras label inserted in the name.


[19]:
from apstools.utils import plotxy

run = cat[uid]

# Compose a title from current conditions.
pos = e6c_hkl.full_position(digits=0)
Q = f"({pos['h']:.0f}, {pos['k']:.0f}, {pos['l']:.0f})"
hkl2 = f"({pos['h2']:.0f}, {pos['k2']:.0f} ,{pos['l2']:.0f})"
title = f"$Q={Q}$ and $hkl_2={hkl2}$"

plotxy(run, "e6c_hkl_extras_psi", "e6c_hkl_phi", stats=False, title=title)
/home/prjemian/.conda/envs/bluesky_2025_1/lib/python3.11/site-packages/databroker/intake_xarray_core/base.py:23: FutureWarning: The return type of `Dataset.dims` will be changed to return a set of dimension names in future, in order to be more consistent with `DataArray.dims`. To access a mapping from dimension names to lengths, please use `Dataset.sizes`.
  'dims': dict(self._ds.dims),
../_images/examples_hkl_soleil-e6c-psi_41_1.png