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

This hklpy notebook demonstrates a diffractometer scan of angle \(\psi\), the component of the reference vector (\(hkl_2\)) that is perpendicular to the scattering vector (\(Q\)). In this schematic:

25322bef9c4e44f4af37773eef4c88a9

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

First, setup bluesky and build the diffractometer object.

[1]:
from bluesky import plan_stubs as bps
from bluesky import preprocessors as bpp
from bluesky import RunEngine
from bluesky.callbacks.best_effort import BestEffortCallback
from hkl import A_KEV
from hkl import new_lattice
from hkl import SimulatedE4CV
from hkl.diffract import Diffractometer
from hkl.util import libhkl
from ophyd import Signal
from ophyd.sim import noisy_det
import databroker
import math
import numpy

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

UserUnits = libhkl.UnitEnum.USER

diffractometer = SimulatedE4CV("", name="diffractometer")

Configure the real-space axes so they are reported in LiveTable. Also, the orientation reflections should be reported with other configuration information, not as primary data.

[2]:
for item in diffractometer.real_positioners:
    item.kind = "hinted"
diffractometer.reflections.kind = "config"

Add a sample of vibranium. The cubic lattice constant is exactly \(2\pi\).

[3]:
diffractometer.calc.new_sample("vibranium", lattice=new_lattice(2 * math.pi))
[3]:
HklSample(name='vibranium', lattice=LatticeTuple(a=6.283185307179586, b=6.283185307179586, c=6.283185307179586, alpha=90.0, beta=90.0, gamma=90.0), ux=Parameter(name='None (internally: ux)', limits=(min=-180.0, max=180.0), value=0.0, fit=True, inverted=False, units='Degree'), uy=Parameter(name='None (internally: uy)', limits=(min=-180.0, max=180.0), value=0.0, fit=True, inverted=False, units='Degree'), uz=Parameter(name='None (internally: uz)', limits=(min=-180.0, max=180.0), value=0.0, fit=True, inverted=False, units='Degree'), U=array([[1., 0., 0.],
       [0., 1., 0.],
       [0., 0., 1.]]), UB=array([[ 1.000000e+00, -6.123234e-17, -6.123234e-17],
       [ 0.000000e+00,  1.000000e+00, -6.123234e-17],
       [ 0.000000e+00,  0.000000e+00,  1.000000e+00]]), reflections=[])

Set the diffractometer’s wavelength by setting the X-ray photon energy. Orient the sample with two observed reflections and compute the \(UB\) orientation matrix.

[4]:
diffractometer.energy.put(A_KEV / 1.54)  # (8.0509 keV)

om = 29.35355
tth = 2 * om
r1 = diffractometer.calc.sample.add_reflection(
    4,
    0,
    0,
    position=diffractometer.calc.Position(tth=tth, omega=om, chi=0, phi=0),
)

r2 = diffractometer.calc.sample.add_reflection(
    0,
    4,
    0,
    position=diffractometer.calc.Position(tth=tth, omega=om, chi=90, phi=0),
)

diffractometer.calc.sample.compute_UB(r1, r2)
[4]:
array([[-5.55111433e-17, -1.11022287e-16, -1.00000000e+00],
       [ 0.00000000e+00,  1.00000000e+00, -1.72254627e-16],
       [ 1.00000000e+00, -6.12323400e-17, -1.16743483e-16]])

bluesky plan_stub to move \(\psi\)#

Create a bluesky plan stub to move \(\psi\) given \(hkl_2\) and \(Q\).

This plan stub has been generalized to set all the extra parameters. It makes no assumption about any specific diffractometer geometry. The default mode can be changed with a keyword argument. When using keyword test=True, the number of digits reported can be changed with a keyword argument.

It is helpful to have a separate plan stub (a plan that does not generate any data streams) to perform the steps needed to change the \(\psi\) value. For the forward computation (hkl to angles), the procedure is:

  1. select the diffractometer mode

  2. set the extra parameters (\(hkl_2\) and \(\psi\))

  3. compute angles from forward(Q)

  4. move diffractometer to the computed angles

NOTE: The \(\psi\) rotation angle is an extra diffractometer parameter, only available as defined by certain diffractometer modes. It is not defined as an ophyd Signal object. It cannot be scanned directly with any of the standard bluesky plans, nor can it be moved with bps.mv(). A custom plan is needed to scan \(\psi\).

[5]:
def move_psi_forward(
    dfrct: Diffractometer,
    Q: dict,  # (h, k, l)
    extras: dict,  # (h2, k2, l2, psi)
    mode: str = "psi_constant",
    test: bool = False,
    digits: int = 5,
):
    """
    Set extras and compute forward solution given 'Q' & reference reflection 'hkl_2'.

    EXAMPLE::

        RE(
            move_psi_forward(
                diffractometer,
                Q=dict(h=2, k=1, l=0),
                extras=dict(h2=2, k2=2, l2=0, psi=25),
            )
        )
    """
    dfrct.engine.mode = mode
    extras = [extras[k] for k in dfrct.calc.parameters]
    dfrct.calc.engine._engine.parameters_values_set(extras, UserUnits)

    # TODO: When test=True, can any moves be avoided?
    solution = dfrct.forward(list(Q.values()))

    reals = []  # convert to ophyd real positioner objects
    for k, v in solution._asdict().items():
        reals.append(getattr(dfrct, k))
        reals.append(v)
    if test:

        def pos_dict(pos_tuple):
            # fmt: off
            return {
                k: round(v, digits) or 0
                for k, v in pos_tuple._asdict().items()
            }
            # fmt: on

        result = pos_dict(dfrct.position)
        result.update(pos_dict(dfrct.real_position))
        result.update(
            dict(
                zip(
                    dfrct.calc.parameters,
                    dfrct.calc.engine._engine.parameters_values_get(UserUnits),
                )
            )
        )
        print(result)
    else:
        yield from bps.mv(*reals)

bluesky plan to scan extra parameters such as \(\psi\)#

Create a generalized bluesky plan that can scan \(\psi\) given \(Q\) & \(hkl_2\). The values of \(hkl_2\) are described as constants in the extras input dictionary. The axis parameter is psi in this example. The pseudos parameter contains the values of \(Q\).

Do not provide a reals keyword. That’s a future feature. It must remain at the default value of None for this example.

[6]:
def scan_extra_parameter(
    dfrct: Diffractometer = None,
    detectors: list = [],
    axis: str = None,  # name of extra parameter to be scanned
    start: float = None,
    finish: float = None,
    num: int = None,
    pseudos: dict = None,  # h, k, l
    reals: dict = None,  # angles
    extras: dict = {},  # define all but the 'axis', these will remain constant
    md: dict = None,
):
    """
    Scan one (or more) extra diffractometer parameter(s), such as psi.

    - iterate extras as decribed:
        - set extras
        - solution = forward(pseudos)
        - move to solution
        - trigger detectors
        - read all controls
    """
    # if pseudos is None and reals is None:
    #     raise SolverError("Must define either pseudos or reals.")
    # if pseudos is not None and reals is not None:
    #     raise SolverError("Cannot define both pseudos and reals.")
    forwardTransformation = reals is None

    _md = {
        "diffractometer": {
            "name": dfrct.name,
            "geometry": dfrct.calc.geometry_name,
            "engine": dfrct.engine.name,
            "mode": dfrct.engine.mode,
            "extra_axes": dfrct.calc.parameters,
        },
        "axis": axis,
        "start": start,
        "finish": finish,
        "num": num,
        "pseudos": pseudos,
        "reals": reals,
        "extras": extras,
        "transformation": "forward" if forwardTransformation else "inverse",
    }
    _md.update(md or {})

    # Make a Signal for psi so it can be reported in LiveTable and any stored data.
    signal = Signal(name=axis, value=start)
    all_controls = detectors
    all_controls.append(dfrct)
    all_controls.append(signal)
    # TODO: controls.append(extras_device)  # TODO: need Device to report ALL extras
    all_controls = list(set(all_controls))

    @bpp.stage_decorator(detectors)
    @bpp.run_decorator(md=_md)
    def _inner():
        for value in numpy.linspace(start, finish, num=num):
            # note the new position for reporting later
            yield from bps.mv(signal, value)

            # move
            extras.update({axis: value})
            if forwardTransformation:
                yield from move_psi_forward(dfrct, Q=pseudos, extras=extras)
            else:
                pass  # TODO: inverse

            # trigger
            group = "scan_extra_parameter_detectors"
            for item in detectors:
                yield from bps.trigger(item, group=group)
            yield from bps.wait(group=group)

            # read & record the data point
            yield from bps.create("primary")
            for item in all_controls:
                yield from bps.read(item)
            yield from bps.save()

    return (yield from _inner())

Scan \(\psi\) over a wide range in coarse steps. This example chooses \(Q=(210)\) and \(hkl_2=(1 \bar2 0)\). (The reference \(hkl_2\) was chosen to be perpendicular to \(Q\).) Save the uid from the scan for later reference.

[10]:
uid, = RE(
    scan_extra_parameter(
        diffractometer,
        detectors=[noisy_det,],
        pseudos=dict(h=2, k=1, l=0),
        axis="psi",
        start=0,
        finish=175,
        num=19,
        extras=dict(h2=1, k2=-2, l2=0),
    ),
)


Transient Scan ID: 2     Time: 2024-09-05 15:57:02
Persistent Unique Scan ID: 'c9c96fe0-c712-433a-abf6-ea1548c8dbd8'
New stream: 'primary'
+-----------+------------+------------+------------------+------------------+------------------+----------------------+--------------------+--------------------+--------------------+------------+
|   seq_num |       time |  noisy_det | diffractometer_h | diffractometer_k | diffractometer_l | diffractometer_omega | diffractometer_chi | diffractometer_phi | diffractometer_tth |        psi |
+-----------+------------+------------+------------------+------------------+------------------+----------------------+--------------------+--------------------+--------------------+------------+
|         1 | 15:57:02.9 |      1.050 |            2.000 |            1.000 |           -0.000 |              -79.339 |            -90.000 |            -90.000 |            -31.808 |      0.000 |
|         2 | 15:57:03.0 |      1.074 |            2.000 |            1.000 |            0.000 |              -79.006 |            -81.313 |            -94.382 |            -31.808 |      9.722 |
|         3 | 15:57:03.0 |      1.072 |            2.000 |            1.000 |           -0.000 |              -77.970 |            -72.678 |            -98.972 |            -31.808 |     19.444 |
|         4 | 15:57:03.0 |      1.085 |            2.000 |            1.000 |           -0.000 |              -76.109 |            -64.157 |           -104.015 |            -31.808 |     29.167 |
|         5 | 15:57:03.0 |      0.901 |            2.000 |            1.000 |           -0.000 |              -73.189 |            -55.838 |           -109.835 |            -31.808 |     38.889 |
|         6 | 15:57:03.0 |      0.944 |            2.000 |            1.000 |           -0.000 |              -68.806 |            -47.853 |           -116.906 |            -31.808 |     48.611 |
|         7 | 15:57:03.0 |      0.960 |            2.000 |            1.000 |           -0.000 |              -62.300 |            -40.424 |           -125.944 |            -31.808 |     58.333 |
|         8 | 15:57:03.0 |      0.957 |            2.000 |            1.000 |           -0.000 |              -52.679 |            -33.940 |           -137.984 |            -31.808 |     68.056 |
|         9 | 15:57:03.0 |      0.935 |            2.000 |            1.000 |            0.000 |              -38.852 |            -29.055 |           -154.156 |            -31.808 |     77.778 |
|        10 | 15:57:03.0 |      0.919 |            2.000 |            1.000 |            0.000 |              -20.890 |            -26.674 |           -174.424 |            -31.808 |     87.500 |
|        11 | 15:57:03.0 |      0.935 |            2.000 |            1.000 |            0.000 |               -1.790 |            -27.460 |            164.179 |            -31.808 |     97.222 |
|        12 | 15:57:03.0 |      0.975 |            2.000 |            1.000 |            0.000 |               14.333 |            -31.174 |            145.735 |            -31.808 |    106.944 |
|        13 | 15:57:03.0 |      1.087 |            2.000 |            1.000 |            0.000 |               26.007 |            -36.938 |            131.684 |            -31.808 |    116.667 |
|        14 | 15:57:03.0 |      1.023 |            2.000 |            1.000 |            0.000 |               33.972 |            -43.944 |            121.251 |            -31.808 |    126.389 |
|        15 | 15:57:03.1 |      0.995 |            2.000 |            1.000 |            0.000 |               39.344 |            -51.679 |            113.277 |            -31.808 |    136.111 |
|        16 | 15:57:03.1 |      0.947 |            2.000 |            1.000 |           -0.000 |               42.951 |            -59.847 |            106.885 |            -31.808 |    145.833 |
|        17 | 15:57:03.1 |      0.980 |            2.000 |            1.000 |           -0.000 |               45.319 |            -68.277 |            101.491 |            -31.808 |    155.556 |
|        18 | 15:57:03.1 |      0.988 |            2.000 |            1.000 |           -0.000 |               46.758 |            -76.862 |             96.702 |            -31.808 |    165.278 |
|        19 | 15:57:03.1 |      1.095 |            2.000 |            1.000 |            0.000 |               47.443 |            -85.529 |             92.241 |            -31.808 |    175.000 |
+-----------+------------+------------+------------------+------------------+------------------+----------------------+--------------------+--------------------+--------------------+------------+
generator scan_extra_parameter ['c9c96fe0'] (scan num: 2)



Get the run data from the catalog using the uid as an index. View the metadata from that run.

[11]:
run = cat[uid]
run.metadata["start"]
[11]:
Start({'axis': 'psi',
 'diffractometer': {'engine': 'hkl',
                    'extra_axes': ['h2', 'k2', 'l2', 'psi'],
                    'geometry': 'E4CV',
                    'mode': 'psi_constant',
                    'name': 'diffractometer'},
 'extras': {'h2': 1, 'k2': -2, 'l2': 0},
 'finish': 175,
 'num': 19,
 'plan_name': 'scan_extra_parameter',
 'plan_type': 'generator',
 'pseudos': {'h': 2, 'k': 1, 'l': 0},
 'reals': None,
 'scan_id': 2,
 'start': 0,
 'time': 1725569822.9740038,
 'transformation': 'forward',
 'uid': 'c9c96fe0-c712-433a-abf6-ea1548c8dbd8',
 'versions': {'bluesky': '1.13.0a5.dev23+g997eb228', 'ophyd': '1.9.0'}})

Create plots of each diffractometer real axis v. \(\psi\) to show how the axes moved during the scan. Use the axis names as recorded in the databroker catalog.

Note: Detector axes (such as tth) remain constant as shown in the LiveTable. The plots show variations past the 6th decimal place due to machine precision.

[17]:
from apstools.utils import plotxy

title = "$Q=(210)$ and $hkl_2=(1, -2, 0)$"
for item in diffractometer.real_positioners:
    plotxy(run, "psi", item.name, stats=False, title=title)
../_images/examples_demo_psi_scan_hklpy_17_0.png
../_images/examples_demo_psi_scan_hklpy_17_1.png
../_images/examples_demo_psi_scan_hklpy_17_2.png
../_images/examples_demo_psi_scan_hklpy_17_3.png