Crystal Analyzer on the Detector Arm#
A crystal analyzer is a passive optical element on the detector arm that
selects a specific scattered wavelength by Bragg diffraction — acting as a
narrow bandpass filter (±Δλ) whose width depends on the crystal composition,
d-spacing, and perfection. With an analyzer in place, the detector relocates to the
analyzer’s attheta arm and points at the analyzer crystal (not the sample);
the atheta arm positions the crystal to select the wavelength.
The control system operates the analyzer motors; hklpy2 treats them as auxiliary positioners that are read alongside the diffractometer but do not affect reciprocal-space calculations.
Objective
Add two analyzer axes (atheta, attheta) to an E4CV diffractometer,
demonstrate their use in a Bluesky session, and show how to save and restore the
diffractometer configuration.
Analyzer axes (nominal names used here)
axis |
description |
|---|---|
|
analyzer crystal Bragg angle (θ) |
|
analyzer detector arm angle (2θ) |
These names are conventional; substitute the names used at your beamline.
Setup#
Create an E4CV diffractometer with the two analyzer axes declared alongside the
four standard real motors. Pass all six axes in the reals dictionary; the
four that the solver knows about (omega, chi, phi, tth) are mapped
automatically; the remaining two (atheta, attheta) appear as auxiliaries.
The solver axes (omega, chi, phi, tth) must appear in the order the
solver expects — giving them out of order will cause incorrect motor mapping.
Auxiliary axes may be appended in any order after the solver axes. See
the Diffractometer axes guide for details.
import hklpy2
e4cv = hklpy2.creator(
name="e4cv",
reals=dict(
omega=None, # simulated motor
chi=None,
phi=None,
tth=None,
atheta=None, # analyzer theta
attheta=None, # analyzer 2-theta
),
)
Show the diffractometer position summary. The analyzer axes appear under auxiliaries.
e4cv.wh()
wavelength=1.0
pseudos: h=0, k=0, l=0
reals: omega=0, chi=0, phi=0, tth=0
auxiliaries: atheta=0, attheta=0
Show all ophyd component names.
print(f"{e4cv.component_names=}")
e4cv.component_names=('beam', 'h', 'k', 'l', 'omega', 'chi', 'phi', 'tth', 'atheta', 'attheta')
Orient the diffractometer#
Add a sample and two orientation reflections, then compute the UB matrix. The analyzer axes play no role in orientation.
e4cv.core.add_sample("vibranium", 4.04)
r1 = e4cv.core.add_reflection(
dict(h=4, k=0, l=0),
dict(omega=-145.451, chi=0, phi=0, tth=69.066),
)
r2 = e4cv.core.add_reflection(
dict(h=0, k=4, l=0),
dict(omega=-145.451, chi=0, phi=90, tth=69.066),
)
e4cv.core.calc_UB(r1, r2)
print("UB matrix computed.")
UB matrix computed.
Set the wavelength and move the analyzer axes#
Set the incident wavelength first. The analyzer is positioned here to select elastic scattering — its Bragg angle is computed from the incident wavelength and the analyzer crystal d-spacing.
import math
d_spacing = 3.1355 # Si(111) d-spacing in angstroms
# Set incident wavelength. At a beamline this may be read-only (driven by the monochromator);
# in that case, skip this line — the analyzer angle is computed from e4cv.beam.wavelength (below).
e4cv.beam.wavelength.put(1.54) # Cu K-alpha in angstroms
# Compute analyzer Bragg angle for elastic scattering (analyzer lambda == incident lambda).
wavelength = e4cv.beam.wavelength.get()
e4cv.atheta.move(math.degrees(math.asin(wavelength / (2 * d_spacing))))
e4cv.attheta.move(e4cv.atheta.position * 2)
print(f"incident wavelength : {wavelength:.4f} Å")
print(f"Si(111) d-spacing : {d_spacing:.4f} Å")
print(f"atheta : {e4cv.atheta.position:.4f} deg")
print(f"attheta : {e4cv.attheta.position:.4f} deg")
e4cv.wh()
incident wavelength : 1.5400 Å
Si(111) d-spacing : 3.1355 Å
atheta : 14.2158 deg
attheta : 28.4316 deg
wavelength=1.54
pseudos: h=0, k=0, l=0
reals: omega=0, chi=0, phi=0, tth=0
auxiliaries: atheta=14.2158, attheta=28.4316
Read all axes together#
Because the analyzer axes are ophyd components on the diffractometer device,
they are included in read() and will be recorded in every Bluesky run
document automatically.
e4cv.read()
OrderedDict([('e4cv_beam_wavelength',
{'value': 1.54, 'timestamp': 1776377183.0549195}),
('e4cv_beam_energy',
{'value': 8.050921976530415, 'timestamp': 1776377182.6812484}),
('e4cv_h', {'value': 0, 'timestamp': 1776377182.681517}),
('e4cv_h_setpoint',
{'value': 0, 'timestamp': 1776377182.6815362}),
('e4cv_k', {'value': 0, 'timestamp': 1776377182.6816278}),
('e4cv_k_setpoint', {'value': 0, 'timestamp': 1776377182.681644}),
('e4cv_l', {'value': 0, 'timestamp': 1776377182.6817188}),
('e4cv_l_setpoint',
{'value': 0, 'timestamp': 1776377182.6817346}),
('e4cv_omega', {'value': 0, 'timestamp': 1776377183.0630844}),
('e4cv_chi', {'value': 0, 'timestamp': 1776377183.0630896}),
('e4cv_phi', {'value': 0, 'timestamp': 1776377183.0630927}),
('e4cv_tth', {'value': 0, 'timestamp': 1776377183.0630956}),
('e4cv_atheta',
{'value': 14.215809201820777, 'timestamp': 1776377183.0630977}),
('e4cv_attheta',
{'value': 28.431618403641554, 'timestamp': 1776377183.0630999})])
Verify the analyzer wavelength#
Back-compute the analyzed wavelength from the current atheta position using
Bragg’s law (λ = 2 d sin θ) and confirm it matches the incident wavelength.
analyzer_wavelength = 2 * d_spacing * math.sin(math.radians(e4cv.atheta.position))
print(f"atheta : {e4cv.atheta.position:.4f} deg")
print(f"Analyzer wavelength : {analyzer_wavelength:.4f} Å")
print(f"Incident wavelength : {e4cv.beam.wavelength.get():.4f} Å")
atheta : 14.2158 deg
Analyzer wavelength : 1.5400 Å
Incident wavelength : 1.5400 Å
analyzer_wavelength = 2 * d_spacing * math.sin(math.radians(e4cv.atheta.position))
print(f"atheta : {e4cv.atheta.position:.4f} deg")
print(f"Analyzer wavelength : {analyzer_wavelength:.4f} Å")
print(f"Incident wavelength : {e4cv.beam.wavelength.get():.4f} Å")
atheta : 14.2158 deg
Analyzer wavelength : 1.5400 Å
Incident wavelength : 1.5400 Å
Save the diffractometer configuration#
Export the diffractometer configuration (orientation, sample, reflections, constraints) to a YAML file. Note that the analyzer axes are auxiliary positioners outside the solver mapping and are not stored in the configuration file. Their positions must be restored separately (e.g. from EPICS PV readback, or by moving them explicitly after restore).
config_file = "e4cv-analyzer.yml"
e4cv.export(config_file, comment="E4CV with analyzer axes example")
print(f"Configuration saved to {config_file}")
Configuration saved to e4cv-analyzer.yml
Restore the configuration#
Restore the diffractometer from the saved file. The auxiliary axes
(atheta, attheta) are saved in the configuration file and restored
automatically — no reals= argument is needed.
e4cv2 = hklpy2.simulator_from_config(config_file)
e4cv2.wh()
wavelength=1.54
pseudos: h=0, k=0, l=0
reals: omega=0, chi=0, phi=0, tth=0
auxiliaries: atheta=0, attheta=0
The orientation (UB matrix, sample, reflections) is fully restored. Two items are not stored in the configuration file and must be set explicitly as part of session startup:
Analyzer positions — default to zero; move them as shown above.
Solver mode — defaults to the solver’s first mode (
bissectorfor E4CV); set it withe4cv2.core.solver.mode = 'desired_mode'.
Verify the restored orientation#
Confirm that forward() gives the same result on both the original and restored
diffractometers.
hkl = dict(h=1, k=0, l=0)
print(f"original: forward{hkl} -> {e4cv.forward(**hkl)[0]:.4f}")
print(f"restored: forward{hkl} -> {e4cv2.forward(**hkl)[0]:.4f}")
original: forward{'h': 1, 'k': 0, 'l': 0} -> -10.9875
restored: forward{'h': 1, 'k': 0, 'l': 0} -> -10.9875
Clean up the example file#
import pathlib
pathlib.Path(config_file).unlink(missing_ok=True)