K4CV : 4-circle kappa diffractometer example#
The kappa geometry replaces the traditional \(\chi\)-ring on a 4-circle diffractometer with an alternative kappa stage that holds the phi stage. The kappa stage is tilted at angle \(\alpha\) (typically 50 degrees) from the \(\omega\) stage.
Note: This example is available as a Jupyter notebook from the hklpy source code website: bluesky/hklpy
Setup the K4CV diffractometer in hklpy#
In hkl K4CV geometry (https://people.debian.org/~picca/hkl/hkl.html#org723c5b9):
For this geometry there is a special parameter \(\alpha\), the angle between the kappa rotation axis and the \(\vec{y}\) direction.
axis |
moves |
rotation axis |
vector |
---|---|---|---|
komega |
sample |
\(-\vec{y}\) |
|
kappa |
sample |
\(\vec{x}\) |
|
kphi |
sample |
\(-\vec{y}\) |
|
tth |
detector |
\(-\vec{y}\) |
|
Define this diffractometer#
Create a Python class that specifies the names of the real-space positioners. We call it KappaFourCircle
here but that choice is arbitrary. Pick any valid Python name not already in use. The convention is to start Python class names with a capital letter and use CamelCase to mark word starts.
The argument to the KappaFourCircle
class tells which hklpy base class will be used. This sets the geometry. (The class we show here could be replaced entirely with hkl.geometries.SimulatedK4CV
but we choose to show here the complete steps to produce that class.) The hklpy documentation provides a complete list of diffractometer geometries.
In hklpy, the reciprocal-space axes are known as pseudo
positioners while the real-space axes are known as real
positioners. For the real positioners, it is possible to use different names than the canonical names used internally by the hkl library. That is not covered here.
Note: The keyword argument kind="hinted"
is an indication that this signal may be plotted.
This KappaFourCircle()
class example uses simulated motors. See the drop-down for an example how to use EPICS motors.
KappaFourCircle() class using EPICS motors
from hkl import K4CV
from ophyd import EpicsMotor, PseudoSingle
from ophyd import Component as Cpt
class KappaFourCircle(K4CV):
"""
Our kappa 4-circle. Vertical scattering orientation.
"""
# the reciprocal axes are called "pseudo" in hklpy
h = Cpt(PseudoSingle, '', kind="hinted")
k = Cpt(PseudoSingle, '', kind="hinted")
l = Cpt(PseudoSingle, '', kind="hinted")
# the motor axes are called "real" in hklpy
komega = Cpt(EpicsMotor, "pv_prefix:m41", kind="hinted")
kappa = Cpt(EpicsMotor, "pv_prefix:m22", kind="hinted")
kphi = Cpt(EpicsMotor, "pv_prefix:m35", kind="hinted")
tth = Cpt(EpicsMotor, "pv_prefix:m7", kind="hinted")
[1]:
from hkl import K4CV, SimMixin
from ophyd import SoftPositioner
from ophyd import Component as Cpt
class KappaFourCircle(SimMixin, K4CV):
"""
Our kappa 4-circle. Vertical scattering orientation.
"""
# the reciprocal axes are defined by SimMixin
komega = Cpt(SoftPositioner, kind="hinted", init_pos=0)
kappa = Cpt(SoftPositioner, kind="hinted", init_pos=0)
kphi = Cpt(SoftPositioner, kind="hinted", init_pos=0)
tth = Cpt(SoftPositioner, kind="hinted", init_pos=0)
Create the Python diffractometer object (sixc
) using the SixCircle()
class. By convention, the name
keyword is the same as the object name.
[2]:
k4cv = KappaFourCircle("", name="k4cv")
Add a sample with a crystal structure#
[3]:
from hkl import Lattice
from hkl import SI_LATTICE_PARAMETER
# add the sample to the calculation engine
a0 = SI_LATTICE_PARAMETER
k4cv.calc.new_sample(
"silicon",
lattice=Lattice(a=a0, b=a0, c=a0, alpha=90, beta=90, gamma=90)
)
[3]:
HklSample(name='silicon', lattice=LatticeTuple(a=5.431020511, b=5.431020511, c=5.431020511, 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.15690694e+00, -7.08401189e-17, -7.08401189e-17],
[ 0.00000000e+00, 1.15690694e+00, -7.08401189e-17],
[ 0.00000000e+00, 0.00000000e+00, 1.15690694e+00]]), reflections=[])
Setup the UB orientation matrix using hklpy#
Define the crystal’s orientation on the diffractometer using the 2-reflection method described by Busing & Levy, Acta Cryst 22 (1967) 457.
Set the same X-ray wavelength for both reflections, by setting the diffractometer energy#
[4]:
from hkl import A_KEV
k4cv.energy.put(A_KEV / 1.54) # (8.0509 keV)
Find the first reflection and identify its Miller indices: (hkl)#
[5]:
r1 = k4cv.calc.sample.add_reflection(
4, 0, 0,
position=k4cv.calc.Position(
tth=-69.0966,
komega=55.4507,
kappa=0,
kphi=-90,
)
)
Find the second reflection#
[6]:
r2 = k4cv.calc.sample.add_reflection(
0, 4, 0,
position=k4cv.calc.Position(
tth=-69.0966,
komega=-1.5950,
kappa=134.7568,
kphi=123.3554
)
)
Compute the UB orientation matrix#
The add_reflection()
method uses the current wavelength at the time it is called. (To add reflections at different wavelengths, change the wavelength before calling add_reflection()
each time.) The compute_UB()
method returns the computed UB matrix. Ignore it here.
[7]:
k4cv.calc.sample.compute_UB(r1, r2)
[7]:
array([[ 2.01918352e-05, -7.20401174e-06, -1.15690694e+00],
[ 0.00000000e+00, -1.15690694e+00, 7.20401174e-06],
[-1.15690694e+00, -1.25733653e-10, -2.01918352e-05]])
Report what we have setup#
[8]:
k4cv.pa()
===================== =================================================================================
term value
===================== =================================================================================
diffractometer k4cv
geometry K4CV
class KappaFourCircle
energy (keV) 8.05092
wavelength (angstrom) 1.54000
calc engine hkl
mode bissector
positions ====== =======
name value
====== =======
komega 0.00000
kappa 0.00000
kphi 0.00000
tth 0.00000
====== =======
constraints ====== ========= ========== ===== ====
axis low_limit high_limit value fit
====== ========= ========== ===== ====
komega -180.0 180.0 0.0 True
kappa -180.0 180.0 0.0 True
kphi -180.0 180.0 0.0 True
tth -180.0 180.0 0.0 True
====== ========= ========== ===== ====
sample: silicon ================= ===============================================================
term value
================= ===============================================================
unit cell edges a=5.431020511, b=5.431020511, c=5.431020511
unit cell angles alpha=90.0, beta=90.0, gamma=90.0
ref 1 (hkl) h=4.0, k=0.0, l=0.0
ref 1 positioners komega=55.45070, kappa=0.00000, kphi=-90.00000, tth=-69.09660
ref 2 (hkl) h=0.0, k=4.0, l=0.0
ref 2 positioners komega=-1.59500, kappa=134.75680, kphi=123.35540, tth=-69.09660
[U] [[ 1.74532925e-05 -6.22695871e-06 -1.00000000e+00]
[ 0.00000000e+00 -1.00000000e+00 6.22695872e-06]
[-1.00000000e+00 -1.08680932e-10 -1.74532925e-05]]
[UB] [[ 2.01918352e-05 -7.20401174e-06 -1.15690694e+00]
[ 0.00000000e+00 -1.15690694e+00 7.20401174e-06]
[-1.15690694e+00 -1.25733653e-10 -2.01918352e-05]]
================= ===============================================================
===================== =================================================================================
[8]:
<pyRestTable.rest_table.Table at 0x7eff90207950>
Check the orientation matrix#
Perform checks with forward (hkl to angle) and inverse (angle to hkl) computations to verify the diffractometer will move to the same positions where the reflections were identified.
Constrain one of the motors to a limited range#
keep
kphi
less than or equal to zero (allowing for small roundoff)
First, we apply constraints directly to the calc
-level support.
[9]:
k4cv.calc["kphi"].limits = (-180, 0.001)
k4cv.show_constraints()
====== ========= ========== ===== ====
axis low_limit high_limit value fit
====== ========= ========== ===== ====
komega -180.0 180.0 0.0 True
kappa -180.0 180.0 0.0 True
kphi -180.0 0.001 0.0 True
tth -180.0 180.0 0.0 True
====== ========= ========== ===== ====
[9]:
<pyRestTable.rest_table.Table at 0x7eff902077d0>
Next, we show how to use additional methods of Diffractometer()
that support undo and reset features for applied constraints. The support is based on a stack (a Python list). A set of constraints is added (apply_constraints()
) or removed (undo_last_constraints()
) from the stack. Or, the stack can be cleared (reset_constraints()
).
method |
what happens |
---|---|
|
Add a set of constraints and use them |
|
Remove the most-recent set of constraints and restore the previous one from the stack. |
|
Set constraints back to initial settings. |
|
Print the current constraints in a table. |
A set of constraints is a Python dictionary that uses the real positioner names (the motors) as the keys. Only those constraints with changes need be added to the dictionary but it is permissable to describe all the real positioners. Each value in the dictionary is a hkl.diffract.Constraint, where the values are specified in this order: low_limit, high_limit, value, fit
.
Apply new constraints using the applyConstraints() method. These add to the existing constraints, as shown in the table.
[10]:
from hkl import Constraint
k4cv.apply_constraints(
{
"komega": Constraint(-0.001, 180, 0, True),
"kappa": Constraint(-90, 90, 0, True),
}
)
k4cv.show_constraints()
====== ========= ========== ===== ====
axis low_limit high_limit value fit
====== ========= ========== ===== ====
komega -0.001 180.0 0.0 True
kappa -90.0 90.0 0.0 True
kphi -180.0 0.001 0.0 True
tth -180.0 180.0 0.0 True
====== ========= ========== ===== ====
[10]:
<pyRestTable.rest_table.Table at 0x7eff90205c10>
Then remove (undo) those new additions.
[11]:
k4cv.undo_last_constraints()
k4cv.show_constraints()
====== ========= ========== ===== ====
axis low_limit high_limit value fit
====== ========= ========== ===== ====
komega -180.0 180.0 0.0 True
kappa -180.0 180.0 0.0 True
kphi -180.0 0.001 0.0 True
tth -180.0 180.0 0.0 True
====== ========= ========== ===== ====
[11]:
<pyRestTable.rest_table.Table at 0x7eff918640d0>
Use bissector
mode#
where tth
= 2*omega
[12]:
k4cv.engine.mode = "bissector"
(400) reflection test#
Check the
inverse
(angles -> (hkl)) computation.Check the
forward
((hkl) -> angles) computation.
Check the inverse calculation: (400)#
To calculate the (hkl) corresponding to a given set of motor angles, call k4cv.inverse((h, k, l))
. Note the second set of parentheses needed by this function.
The values are specified, without names, in the order specified by k4cv.calc.physical_axis_names
.
[13]:
print("axis names:", k4cv.calc.physical_axis_names)
axis names: ['komega', 'kappa', 'kphi', 'tth']
Now, proceed with the inverse calculation.
[14]:
sol = k4cv.inverse((55.4507, 0, -90, -69.0966))
print(f"(4 0 0) ? {sol.h:.2f} {sol.k:.2f} {sol.l:.2f}")
(4 0 0) ? 4.00 -0.00 -0.00
Check the forward calculation: (400)#
Compute the angles necessary to position the diffractometer for the given reflection.
Note that for the forward computation, more than one set of angles may be used to reach the same crystal reflection. This test will report the default selection. The default selection (which may be changed through methods described in the hkl.calc
module) is the first solution.
function |
returns |
---|---|
|
The default solution |
|
List of all allowed solutions. |
[15]:
sol = k4cv.forward((4, 0, 0))
print(
"(400) :",
f"tth={sol.tth:.4f}",
f"komega={sol.komega:.4f}",
f"kappa={sol.kappa:.4f}",
f"kphi={sol.kphi:.4f}"
)
(400) : tth=-69.0982 komega=55.4509 kappa=0.0000 kphi=-90.0010
(040) reflection test#
Repeat the inverse
and forward
calculations for the second orientation reflection.
Check the inverse calculation: (040)#
[16]:
sol = k4cv.inverse((-1.5950, 134.7568, 123.3554, -69.0966))
print(f"(0 4 0) ? {sol.h:.2f} {sol.k:.2f} {sol.l:.2f}")
(0 4 0) ? -0.00 4.00 0.00
Check the forward calculation: (040)#
[17]:
sol = k4cv.forward((0, 4, 0))
print(
"(040) :",
f"tth={sol.tth:.4f}",
f"komega={sol.komega:.4f}",
f"kappa={sol.kappa:.4f}",
f"kphi={sol.kphi:.4f}"
)
(040) : tth=-69.0982 komega=-1.5937 kappa=134.7551 kphi=-57.0461
Scan in reciprocal space using Bluesky#
To scan with Bluesky, we need more setup.
[18]:
%matplotlib inline
from bluesky import RunEngine
from bluesky import SupplementalData
from bluesky.callbacks.best_effort import BestEffortCallback
from bluesky.magics import BlueskyMagics
import bluesky.plans as bp
import bluesky.plan_stubs as bps
import databroker
from IPython import get_ipython
import matplotlib.pyplot as plt
plt.ion()
bec = BestEffortCallback()
db = databroker.temp().v1
sd = SupplementalData()
get_ipython().register_magics(BlueskyMagics)
RE = RunEngine({})
RE.md = {}
RE.preprocessors.append(sd)
RE.subscribe(db.insert)
RE.subscribe(bec)
[18]:
1
(h00) scan near (400)#
In this example, we have no detector. Still, we add the diffractometer object in the detector list so that the hkl and motor positions will appear as columns in the table.
[19]:
RE(bp.scan([k4cv], k4cv.h, 3.9, 4.1, 5))
Transient Scan ID: 1 Time: 2023-11-20 21:59:43
Persistent Unique Scan ID: '15166a92-8209-484a-af30-ee7ea176a796'
New stream: 'primary'
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
| seq_num | time | k4cv_h | k4cv_k | k4cv_l | k4cv_komega | k4cv_kappa | k4cv_kphi | k4cv_tth |
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
| 1 | 21:59:43.4 | 3.900 | -0.000 | -0.000 | 56.431 | -0.000 | -90.001 | -67.137 |
| 2 | 21:59:44.0 | 3.950 | -0.000 | 0.000 | 55.943 | -0.000 | -90.001 | -68.115 |
| 3 | 21:59:44.6 | 4.000 | -0.000 | -0.000 | 55.451 | 0.000 | -90.001 | -69.098 |
| 4 | 21:59:45.2 | 4.050 | -0.000 | 0.000 | 54.956 | -0.000 | -90.001 | -70.087 |
| 5 | 21:59:45.8 | 4.100 | 0.000 | 0.000 | 54.459 | -0.000 | -90.001 | -71.083 |
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
generator scan ['15166a92'] (scan num: 1)
[19]:
('15166a92-8209-484a-af30-ee7ea176a796',)
(hk0) scan from (400) to (040)#
Scan between the two orientation reflections. Need to keep \(\varphi\ge0\) to avoid big jumps during the scan.
[20]:
k4cv.calc.engine.mode = "constant_phi"
k4cv.apply_constraints(
{
"kphi": Constraint(0, 180, 0, True),
}
)
RE(bp.scan([k4cv], k4cv.h, 4, 0, k4cv.k, 0, 4, 10))
Transient Scan ID: 2 Time: 2023-11-20 21:59:47
Persistent Unique Scan ID: '9f4b12f0-94f7-46cd-a926-d564bbe1717f'
New stream: 'primary'
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
| seq_num | time | k4cv_h | k4cv_k | k4cv_l | k4cv_komega | k4cv_kappa | k4cv_kphi | k4cv_tth |
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
| 1 | 21:59:47.6 | 4.000 | 0.000 | 0.000 | -124.550 | -0.000 | 90.000 | -69.098 |
| 2 | 21:59:48.1 | 3.556 | 0.444 | 0.000 | -117.539 | -9.305 | 92.995 | -61.064 |
| 3 | 21:59:48.5 | 3.111 | 0.889 | 0.000 | -110.558 | -20.863 | 96.749 | -54.611 |
| 4 | 21:59:48.9 | 2.667 | 1.333 | -0.000 | -103.581 | -34.906 | 101.425 | -50.010 |
| 5 | 21:59:49.3 | 2.222 | 1.778 | 0.000 | -96.678 | -51.202 | 107.118 | -47.591 |
| 6 | 21:59:49.8 | 1.778 | 2.222 | 0.000 | -90.012 | -68.872 | 113.784 | -47.591 |
| 7 | 21:59:50.2 | 1.333 | 2.667 | -0.000 | -83.767 | -86.675 | 121.238 | -50.010 |
| 8 | 21:59:50.6 | 0.889 | 3.111 | 0.000 | -78.039 | -103.647 | 129.267 | -54.611 |
| 9 | 21:59:51.0 | 0.444 | 3.556 | -0.000 | -72.737 | -119.520 | 137.795 | -61.064 |
| 10 | 21:59:51.4 | -0.000 | 4.000 | 0.000 | -67.504 | -134.756 | 147.045 | -69.098 |
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
generator scan ['9f4b12f0'] (scan num: 2)
[20]:
('9f4b12f0-94f7-46cd-a926-d564bbe1717f',)
(0k0) scan near (040)#
[21]:
RE(bp.scan([k4cv], k4cv.k, 3.9, 4.1, 5))
Transient Scan ID: 3 Time: 2023-11-20 21:59:52
Persistent Unique Scan ID: 'd7406f6c-d8bb-4296-8443-dc811d6b511b'
New stream: 'primary'
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
| seq_num | time | k4cv_k | k4cv_h | k4cv_l | k4cv_komega | k4cv_kappa | k4cv_kphi | k4cv_tth |
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
| 1 | 21:59:52.8 | 3.900 | -0.000 | 0.000 | -66.523 | -134.756 | 147.045 | -67.137 |
| 2 | 21:59:53.4 | 3.950 | -0.000 | -0.000 | -67.012 | -134.756 | 147.045 | -68.115 |
| 3 | 21:59:54.0 | 4.000 | -0.000 | 0.000 | -67.504 | -134.756 | 147.045 | -69.098 |
| 4 | 21:59:54.6 | 4.050 | -0.000 | 0.000 | -67.998 | -134.756 | 147.045 | -70.087 |
| 5 | 21:59:55.2 | 4.100 | -0.000 | 0.000 | -68.496 | -134.756 | 147.045 | -71.083 |
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
generator scan ['d7406f6c'] (scan num: 3)
[21]:
('d7406f6c-d8bb-4296-8443-dc811d6b511b',)
(hk0) scan near (440)#
[22]:
RE(bp.scan([k4cv], k4cv.h, 3.9, 4.1, k4cv.k, 4.1, 3.9, 5))
Transient Scan ID: 4 Time: 2023-11-20 21:59:56
Persistent Unique Scan ID: '7351e671-a1ae-465d-8f77-e0b33efa8ea5'
New stream: 'primary'
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
| seq_num | time | k4cv_h | k4cv_k | k4cv_l | k4cv_komega | k4cv_kappa | k4cv_kphi | k4cv_tth |
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
| 1 | 21:59:57.0 | 3.900 | 4.100 | 0.000 | -122.253 | -61.940 | 111.095 | -106.695 |
| 2 | 21:59:57.4 | 3.950 | 4.050 | 0.000 | -122.614 | -60.940 | 110.715 | -106.659 |
| 3 | 21:59:57.8 | 4.000 | 4.000 | -0.000 | -122.985 | -59.941 | 110.339 | -106.647 |
| 4 | 21:59:58.3 | 4.050 | 3.950 | 0.000 | -123.365 | -58.946 | 109.965 | -106.659 |
| 5 | 21:59:58.7 | 4.100 | 3.900 | 0.000 | -123.754 | -57.952 | 109.593 | -106.695 |
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
generator scan ['7351e671'] (scan num: 4)
[22]:
('7351e671-a1ae-465d-8f77-e0b33efa8ea5',)
Move to the (440) reflection.
[23]:
k4cv.move((4,4,0))
print(f"{k4cv.position = }")
k4cv.position = KappaFourCirclePseudoPos(h=4.000000003222204, k=3.999999988853611, l=-1.8832509383931196e-08)
Repeat the same scan about the (440) but use relative positions.
[24]:
RE(bp.rel_scan([k4cv], k4cv.h, -0.1, 0.1, k4cv.k, -0.1, 0.1, 5))
Transient Scan ID: 5 Time: 2023-11-20 22:00:00
Persistent Unique Scan ID: '18ac1987-606c-4a33-9470-f276ecb1036f'
New stream: 'primary'
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
| seq_num | time | k4cv_h | k4cv_k | k4cv_l | k4cv_komega | k4cv_kappa | k4cv_kphi | k4cv_tth |
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
| 1 | 22:00:00.2 | 3.900 | 3.900 | 0.000 | -121.103 | -59.941 | 110.339 | -102.882 |
| 2 | 22:00:00.6 | 3.950 | 3.950 | -0.000 | -122.034 | -59.941 | 110.339 | -104.744 |
| 3 | 22:00:01.0 | 4.000 | 4.000 | -0.000 | -122.985 | -59.941 | 110.339 | -106.647 |
| 4 | 22:00:01.4 | 4.050 | 4.050 | -0.000 | -123.958 | -59.941 | 110.339 | -108.592 |
| 5 | 22:00:01.9 | 4.100 | 4.100 | -0.000 | -124.954 | -59.941 | 110.339 | -110.585 |
+-----------+------------+------------+------------+------------+-------------+------------+------------+------------+
generator rel_scan ['18ac1987'] (scan num: 5)
[24]:
('18ac1987-606c-4a33-9470-f276ecb1036f',)